asterism 0.4.1 → 0.6.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: fd2f9fe700f5ca0b88f98fc6598217c87be3257e31a655d33ba710f9260b76ea
4
- data.tar.gz: '0943fd816e28b2a60c5d812095d5c87d9ac0f1a8d1a0aaf7428989f34526aeb5'
3
+ metadata.gz: ba14899dd37f9c4c21e148a2256d71545785da6f61a1f8f6701e2d0d189e9061
4
+ data.tar.gz: 207e08ed177ffa93cd5c7173a84c31f519f59c8e076c6c641ccaa8db27712ea9
5
5
  SHA512:
6
- metadata.gz: 86426bc1893654601c5a7b9234b09d82b10c6e8260553160f2484b2665ec0eba6bf4293fa1994f88b814997cbad2c69af2d4b2a492f034303ee20c61145f3f43
7
- data.tar.gz: 387f4b1de6234f98b3c4b71ce0aee0d6f4d70ec32250350786cff73ec4cf1d500a64277b90f389f2544465409279aba601ac67fd0b3470919f0902727a21334e
6
+ metadata.gz: de0f85b8c36d4b3c991603004f151ea68952bd7fcc7fdbc76b992af80505b45be3612c5975e509e4f1415f553db8d91ea94940817c00768705bdbf1a011d45e6
7
+ data.tar.gz: df7634a6c9453fb647c0f148199ff056c646451e3c839ac5cf02704db0ee7dfa7508d8aabdd9fde8005b8909dd6c7217f2d2b96a6e066ac6895d39d9a2af9481
data/CHANGELOG.md CHANGED
@@ -6,6 +6,81 @@ 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.6.0
10
+
11
+ One connection for everything and a lighter `Asterism.poll`; every 0.5.x
12
+ call works as before. Needs asterism-zenoh / picoruby-asterism-zenoh 0.4.0
13
+ (unchanged).
14
+
15
+ Added
16
+
17
+ - `Asterism.session` (README "One connection"): the Zenoh session of
18
+ `Asterism.connect` (an `Asterism::Zenoh::Session`), for put, subscribe, a
19
+ ROS 2 node (`Asterism::ROS::Node.new(Asterism.session, ...)`) and the rest
20
+ of the Zenoh API on the same connection. `Disconnected` when not
21
+ connected. The session stays Asterism's: its `close` does nothing (it
22
+ warns once); `Asterism.close` and a lost connection close it, with
23
+ everything declared on it. A router that lets a one-time password in
24
+ once now takes such an application, and its ROS 2 node has the node's
25
+ Zenoh ID (a console draws it inside the node).
26
+ - The CRuby API: `net.session`, the same session as a `Zenoh::Connection`
27
+ on the `Net`'s receiving thread (`shared?`; its `close` does nothing);
28
+ `Asterism::ROS::Connection.new(net.session)` makes ROS 2 nodes on it, and
29
+ its `close` withdraws only its nodes. `Zenoh::Connection#shared?`.
30
+ `ROS::Node#close` on a `ROS::Connection` also stops polling the node.
31
+ - `rake bench:poll` (`test/bench_poll.rb`): the time and the allocations
32
+ of one `Asterism.poll` by the number of nodes and objects.
33
+ - Tests: `test/test_session.rb`, `test/test_api_session.rb`, and on mruby
34
+ `host_test/test_session.rb` with `host_test/peer.rb` (the mruby test
35
+ build adds mruby-eval for the ROS 2 type files).
36
+
37
+ Changed
38
+
39
+ - `Asterism.poll` does less when nothing came: each queue (calls, profile
40
+ queries, liveliness) is read only when its count is not zero, and the
41
+ list of nodes and the comparison behind `on_join` / `on_leave` are made
42
+ again only after a liveliness change, with Hashes instead of `include?`.
43
+ The behaviour is the same (the order and number of the `on_join` /
44
+ `on_leave` calls, the order of `Asterism.nodes`). With 4 other nodes and
45
+ 5 objects, a poll in which nothing changed: CRuby 3.2 us and 49
46
+ allocations -> 0.6 us and 7; mruby (host) 14-17 us -> 2.2 us.
47
+ `Asterism.each` drops duplicates with a Hash.
48
+
49
+ ## 0.5.0
50
+
51
+ Node profiles; every 0.4.x call works as before. Needs asterism-zenoh /
52
+ picoruby-asterism-zenoh 0.4.0 (unchanged).
53
+
54
+ Added
55
+
56
+ - Node profiles (README "Profiles"): `Asterism.profile(kind:, name:, host:,
57
+ icon:)` sets this node's profile (the fields given; the rest stay) and
58
+ returns it, `Asterism.profile` returns it, `Asterism.profile_of(node,
59
+ icon: true, timeout: 2.0)` asks another node's (nil when it does not
60
+ answer). The CRuby API: `net.profile`, `net.profile_of`. Answered on
61
+ `asterism/<node>/@profile` (a queryable of every connected application,
62
+ MessagePack Hash: `kind`, `host`, `name`, `icon`, `icon_hash`; the
63
+ parameter `icon=0` leaves the icon out), and put on the same key without
64
+ the icon at connect and on each change.
65
+ - Defaults: `kind` is `cruby` on CRuby and `picoruby` on mruby / PicoRuby;
66
+ the icon is the file at `Asterism.default_icon_path` when it is there:
67
+ the gem's `data/icon.png` on CRuby (new; a ruby, 32x32), and
68
+ `/usr/share/asterism/icon.png` on the boards.
69
+ - `Asterism::Profile`: `png_info` (the icon checks: PNG signature, chunks
70
+ from IHDR to IEND, 8 KB, 256x256; and `icon_hash`, the Adler-32 of the
71
+ chunks' CRCs), `check` (cleans a received profile: an icon that does not
72
+ pass is dropped), the limits as constants.
73
+ - `rake test:mruby` and `host_test/`: the shared layer on mruby (built with
74
+ picoruby-asterism-zenoh, MRB_UTF8_STRING as on the boards), profiles
75
+ between two mruby processes. CI runs it.
76
+
77
+ Changed
78
+
79
+ - The "Ruby profile" (0.4.0) is now called the Ruby feature table, so that
80
+ it is not taken for a node's profile: `docs/ruby_profile.md` is
81
+ `docs/ruby_features.md`. The probes' directory `profile/` and its code
82
+ keep their names.
83
+
9
84
  ## 0.4.1
10
85
 
11
86
  Additions and a fix; every 0.4.0 call works as before. Needs
data/README.md CHANGED
@@ -10,10 +10,12 @@ repository holds the pure Ruby layers, written once for every Ruby:
10
10
  | `mrblib/` | The layers: remote objects (`Asterism`), `Asterism::CDR`, `Asterism::ROS`. The source of truth for both forms below |
11
11
  | `mrbgem.rake` | The mrbgem `picoruby-asterism` (mruby / PicoRuby): compiles `mrblib/` |
12
12
  | `asterism.gemspec`, `lib/` | The CRuby gem `asterism`: `lib/asterism.rb` loads `mrblib/` as it is, `lib/asterism/cruby.rb` adds what CRuby needs, `lib/asterism/cruby/` the CRuby API (CRuby only) |
13
+ | `data/icon.png` | CRuby's default profile icon (32x32 PNG; [Profiles](#profiles)) |
13
14
  | `data/msgs/` | ROS 2 message types generated from ROS 2 Jazzy's definitions (Apache-2.0; tf2_msgs BSD-3-Clause; see NOTICE) |
14
15
  | `tools/` | The type generator `asterism_msggen.rb`, the Jazzy definitions it reads, `ros2_types.rb` (refreshes them from a ROS 2 image) |
15
- | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_api_*.rb` (the CRuby API) |
16
- | `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), `zenoh.rb` (plain Zenoh) |
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
+ | `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
+ | `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) |
17
19
 
18
20
  The Zenoh binding underneath is a separate gem with the same Ruby API on
19
21
  each Ruby:
@@ -35,7 +37,12 @@ Both gems are on rubygems.org (`gem install asterism` installs
35
37
  asterism-zenoh too). What changed in each version is in
36
38
  [CHANGELOG.md](CHANGELOG.md); 0.4.0 is the first step toward 1.0
37
39
  ([docs/api_review.md](docs/api_review.md)) and only adds and deprecates
38
- (see [Deprecations and 1.0](#deprecations-and-10)).
40
+ (see [Deprecations and 1.0](#deprecations-and-10)); 0.5.0 adds node
41
+ profiles ([Profiles](#profiles): what a node runs on, where, a name and an
42
+ icon); 0.6.0 adds `Asterism.session`
43
+ ([One connection](#one-connection-asterismsession): the object layer's
44
+ session for plain Zenoh and ROS 2 too) and makes `Asterism.poll` cheap when
45
+ nothing changed.
39
46
 
40
47
  ## Which API?
41
48
 
@@ -55,6 +62,7 @@ Entry points and what they give:
55
62
  | `Asterism::Zenoh::Session.open` | a `Session` (portable) | zenoh-c / zenoh-pico |
56
63
  | `Asterism::Zenoh.open` | a `Zenoh::Connection` (CRuby) | `c.session` is the `Session` |
57
64
  | `Asterism.connect` | `Asterism` itself (portable; one connection per process or VM), or a `Net` to the block (CRuby) | a `Session` of its own |
65
+ | `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) |
58
66
  | `Asterism::ROS::Node.new(session, ...)` | a `Node` (portable) | the `Session` given |
59
67
  | `Asterism::ROS.connect` | a `ROS::Connection` (CRuby) | `ros.zenoh` is the `Zenoh::Connection` |
60
68
 
@@ -83,7 +91,7 @@ build but not yet tested. To install from the repositories instead:
83
91
 
84
92
  ```
85
93
  (cd ../asterism-zenoh && rake gem) && rake gem
86
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.4.0.gem
94
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.0.gem pkg/asterism-0.6.0.gem
87
95
  ```
88
96
 
89
97
  From the working trees, without installing:
@@ -111,8 +119,18 @@ rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only;
111
119
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
112
120
  rake test:api # the CRuby API (blocks, receiving threads, Enumerators)
113
121
  ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
122
+ rake test:mruby # the shared layer on mruby (builds mruby; not part of `rake`)
123
+ rake bench:poll # the time and allocations of one Asterism.poll by nodes and objects (prints a table)
114
124
  ```
115
125
 
126
+ `test:mruby` builds mruby with picoruby-asterism-zenoh and this gem
127
+ (MRB_UTF8_STRING, as on Family mruby's boards) and runs `host_test/`: two
128
+ mruby processes over a local peer link. It takes the mruby and zenoh-pico
129
+ pinned by a picoruby-asterism-zenoh checkout next to this one (its
130
+ `rake mruby:fetch zenoh:fetch`), or `PICORUBY_ASTERISM_ZENOH_DIR` /
131
+ `MRUBY_DIR`. A small MessagePack for the test is in
132
+ `host_test/msgpack.rb` (the boards bring their own).
133
+
116
134
  `test:objects` and `test:api` need asterism-zenoh: built next to this repository
117
135
  (`../asterism-zenoh`, its `rake compile`), or `ASTERISM_ZENOH_DIR`, or the
118
136
  installed gem. The tests run with deprecated calls raising
@@ -131,6 +149,17 @@ ruby examples/node_polled.rb --router tcp/192.0.2.2:7447 # the same, polled
131
149
  # /cruby/add_two_ints served, AddTwoInts calls
132
150
  ruby examples/ros2_talker.rb --router tcp/192.0.2.2:7447 [--service NAME]
133
151
 
152
+ # ROS 2: drive a robot (made for family-mruby's MuJoCo rover,
153
+ # docker-compose.mujoco.yml): 1 m forward and 90 degrees left by /odom,
154
+ # compared with /ground_truth/odom; or the keyboard
155
+ ruby examples/ros2_rover.rb --router tcp/192.0.2.2:7447 [--keys]
156
+
157
+ # MuJoCo from CRuby (Fiddle), the robot as objects mujoco/rover/{drive,state,world};
158
+ # the MuJoCo release is fetched into examples/mujoco/vendor (examples/mujoco/README.md)
159
+ ruby examples/mujoco/fetch.rb
160
+ ruby examples/mujoco/rover.rb --router tcp/192.0.2.2:7447
161
+ ruby examples/mujoco/drive.rb --router tcp/192.0.2.2:7447 [--keys]
162
+
134
163
  # plain Zenoh: run two with different --name
135
164
  ruby examples/zenoh.rb --router tcp/192.0.2.2:7447 --name pc1
136
165
  ```
@@ -227,6 +256,8 @@ end
227
256
  | `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) |
228
257
  | `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 |
229
258
  | `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 |
259
+ | `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 |
260
+ | `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) |
230
261
  | `net.nodes`, `node_id`, `app`, `connected?`, `lost_reason`, `poll`, `close` | |
231
262
 
232
263
  ### Over TLS (a router on the internet)
@@ -282,6 +313,7 @@ end
282
313
  | `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)) |
283
314
  | `ros.spin` (`run`) / `start` / `stop` / `running?` / `on_error` / `close` | |
284
315
  | `ros.zenoh` | the `Asterism::Zenoh::Connection` underneath (same receiving thread) |
316
+ | `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 |
285
317
 
286
318
  `type` is a generated type or its name (`"geometry_msgs/msg/Twist"`).
287
319
  Messages and `Attachment` take part in pattern matching (`deconstruct_keys`,
@@ -387,7 +419,7 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
387
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). |
388
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. |
389
421
  | `Asterism.unexpose(name)` | true / false | |
390
- | `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. |
422
+ | `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. |
391
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). |
392
424
  | `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. |
393
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`). |
@@ -399,7 +431,115 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
399
431
  | `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. |
400
432
  | `Asterism.nodes` | `[String]` | Node IDs alive now, this one first. |
401
433
  | `Asterism.connected?` / `node_id` / `app` / `exposed` / `lost_reason` | | |
434
+ | `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. |
402
435
  | `Asterism.close` | nil | Withdraws every exposed object. Idempotent. |
436
+ | `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). |
437
+ | `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). |
438
+
439
+ ## Profiles
440
+
441
+ A node tells what it is (0.5.0): what Ruby runs it, where, a display name
442
+ and an icon. A console draws it with that; anyone can ask.
443
+
444
+ ```ruby
445
+ Asterism.profile(name: "Rover", icon: File.binread("rover.png")) # this node's (any time)
446
+ Asterism.profile
447
+ # => {"kind" => "cruby", "name" => "Rover", "icon" => "\x89PNG...", "icon_hash" => "2a8c0626"}
448
+ Asterism.profile_of("fmruby-aaaaaa")
449
+ # => {"kind" => "family_mruby", "host" => "atoms3r", "icon" => "\x89PNG...", "icon_hash" => "..."}
450
+ Asterism.profile_of("fmruby-aaaaaa", icon: false) # the same without the icon bytes
451
+ Asterism.profile_of("an-older-node") # => nil
452
+ ```
453
+
454
+ | Field | Value | Default |
455
+ |---|---|---|
456
+ | `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
+ | `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
+ | `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` |
460
+ | `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
+
462
+ - **Setting.** `Asterism.profile(**fields)` changes the fields given and keeps
463
+ the others; `nil` leaves `name` / `host` out, goes back to the default
464
+ `kind`, and means no icon for `icon:`. It raises `ArgumentError` for a
465
+ field it does not accept (an unknown name, a `kind` that is not a word, an
466
+ icon that is not a PNG, larger than 8 KB or 256x256). It may be called
467
+ before connecting and at any time after; the profile belongs to the
468
+ process (the VM on a board) and survives `close`. A firmware can set its
469
+ defaults with the same call before the application runs (Family mruby
470
+ sets `kind`, `host` and, through `/usr/share/asterism/icon.png`, the icon).
471
+ - **Reading.** `Asterism.profile_of(node)` asks `asterism/<node>/@profile`
472
+ and waits like a call (`timeout:` / `timeout_ms:`); this node's own comes
473
+ without the network. The answer is cleaned (`Asterism::Profile.check`):
474
+ only the fields above, each checked; an icon that is not an acceptable
475
+ PNG is dropped with its hash, an answer without a valid `kind` is `nil`.
476
+ `icon: false` asks without the icon (the parameter `icon=0`): a reader
477
+ that already has the icon of that `icon_hash` asks this way.
478
+ - **Following.** At each connect and at each change while connected, the
479
+ node also puts its profile without the icon on `asterism/<node>/@profile`;
480
+ subscribe to `asterism/*/@profile` to follow the changes (and fetch the
481
+ icon again only when `icon_hash` changed). Older nodes neither answer nor
482
+ put.
483
+ - **Several applications of one node** (a board running two): each answers
484
+ for the node, and `profile_of` takes the first answer. Give them the same
485
+ profile (Family mruby's firmware defaults are the same in each), or set it
486
+ in one and read the notices, where the last change wins.
487
+ - `Asterism::Profile.png_info(bytes)` gives `[width, height, icon_hash]` of
488
+ an acceptable PNG (nil otherwise), for a tool that wants to check an icon
489
+ first.
490
+
491
+ ## One connection: `Asterism.session`
492
+
493
+ An application that also puts, subscribes or runs a ROS 2 node uses the
494
+ object layer's own session for it (0.6.0) instead of opening a second one:
495
+
496
+ ```ruby
497
+ Asterism.connect("tcp/192.0.2.2:7447", node: "fmruby-aaaaaa", app: "game")
498
+ Asterism.expose("othello", game, methods: [:move, :state])
499
+ s = Asterism.session # an Asterism::Zenoh::Session
500
+ s.put("asterism/fmruby-aaaaaa/game/othello/state", MessagePack.pack(state))
501
+ moves = s.subscribe("asterism/*/game/othello/state") # moves.each_pending in the update loop
502
+ node = Asterism::ROS::Node.new(s, "brain") # a ROS 2 node with the same Zenoh ID
503
+ cmd = node.publisher("/cmd_vel", "geometry_msgs/msg/Twist")
504
+ # update loop: Asterism.poll; node.poll
505
+ ```
506
+
507
+ CRuby API:
508
+
509
+ ```ruby
510
+ Asterism.connect(router, node: "mypc", app: "demo") do |net|
511
+ net.session.subscribe("asterism/*/game/**") { |sample| p sample.key } # on the receiving thread
512
+ ros = Asterism::ROS::Connection.new(net.session)
513
+ ros.node("brain").subscribe("/odom", "nav_msgs/msg/Odometry") { |odom| puts odom.pose.pose.position.x }
514
+ net.run
515
+ end
516
+ ```
517
+
518
+ - **Why one.** A router that lets each pass in once (a one-time password
519
+ at a `wss://` door) lets only the first session in; and a ROS 2 node on
520
+ the object layer's session has the node's Zenoh ID, so a console draws it
521
+ inside the node.
522
+ - **Who closes it.** The session is Asterism's. `Asterism.session.close`
523
+ (and `net.session.close`) does nothing but warn once, so a library handed
524
+ the session cannot break the connection. `Asterism.close`, the end of the
525
+ connect block and a lost connection close it, and with it everything
526
+ declared on it: the application's subscribers, publishers, ROS 2 nodes.
527
+ Close those yourself when you are done with them earlier (`sub.close`,
528
+ `node.close`; on CRuby `ros.close` withdraws its nodes and leaves the
529
+ rest).
530
+ - **After a lost connection** (`Asterism.poll` returned false), connect
531
+ again, take `Asterism.session` again (a new object) and declare again
532
+ what you had on the old one.
533
+ - **Polling.** `Asterism.poll` polls the session; `node.poll` polls it too
534
+ and only then serves the node. Each subscriber, queryable and node keeps
535
+ what came for it, so the order does not matter and nothing is taken from
536
+ another. Your own `put` does not come back to your own subscriber on the
537
+ same session.
538
+ - **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
540
+ `asterism/<node>/<app>/<object>` are its liveliness tokens. Putting under
541
+ `asterism/<node>/<app>/<object>/<anything>` (a state, a log) is fine:
542
+ puts and subscriptions do not meet queryables or liveliness.
403
543
 
404
544
  ## Errors
405
545
 
@@ -441,6 +581,7 @@ is closed (1.0: `Disconnected`, as the object layer).
441
581
  | Receiving interval of the CRuby API | 2 ms | `interval:` |
442
582
  | Calls waiting inside each other | 4 | `MAX_NESTING` |
443
583
  | Encoding depth of a value | 16 | `Codec::MAX_DEPTH` |
584
+ | A profile icon | 8 KB, 256x256, PNG only | `Asterism::Profile::ICON_MAX`, `ICON_SIDE_MAX` |
444
585
  | `get` consolidation | `:none` (every reply; zenoh's own default is `:auto`, but services and the object layer want every reply) | `consolidation:` |
445
586
 
446
587
  ### Queues
@@ -511,7 +652,9 @@ Asterism follows `Asterism::Zenoh`: the connection is closed when the router
511
652
  why, and calls raise `Asterism::Disconnected`. **`Asterism::Zenoh::Error`
512
653
  never comes out of Asterism; it is always wrapped in `Disconnected`** (the
513
654
  message is kept). The exposed objects are gone with the connection: to go
514
- on, the application calls `connect` and `expose` again.
655
+ on, the application calls `connect` and `expose` again, and declares again
656
+ what it had on `Asterism.session` (subscribers, ROS 2 nodes; the old
657
+ session is closed, its entities raise `Zenoh::ClosedError`).
515
658
 
516
659
  ## Values
517
660
 
@@ -530,8 +673,10 @@ caller gets `RemoteError` (`Asterism::EncodeError`). Nesting is limited to
530
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 |
531
674
  | `asterism/<node>` | liveliness | the node is connected |
532
675
  | `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) |
533
677
 
534
- One queryable per application (`asterism/<node>/<app>/**`). A call whose
678
+ One queryable per application (`asterism/<node>/<app>/**`), and one for the
679
+ profile (`asterism/<node>/@profile`). A call whose
535
680
  node or app is a wildcard reaches every application that exposes that
536
681
  object, each answering with its own key; `Asterism` itself only sends calls
537
682
  to one object.
@@ -601,7 +746,7 @@ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
601
746
  different on Family mruby's PicoRuby: `^x` of an outer local inside a
602
747
  block, `^(expr)`, `Const[...]` and `**rest` next to another key. The full
603
748
  list of what the boards' Ruby can and cannot do is in
604
- [docs/ruby_profile.md](docs/ruby_profile.md).
749
+ [docs/ruby_features.md](docs/ruby_features.md) (the Ruby feature table).
605
750
 
606
751
  ## ROS 2 (rmw_zenoh): `Asterism::ROS` and `Asterism::CDR`
607
752
 
data/data/icon.png ADDED
Binary file
@@ -26,7 +26,8 @@ module Asterism
26
26
  # The portable API, entered under LOCK. Prepended to the Asterism module's
27
27
  # singleton class, so the shared methods themselves are not changed.
28
28
  module Locked
29
- %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave].each do |m|
29
+ %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave
30
+ profile profile_async session].each do |m|
30
31
  define_method(m) do |*args, **kw, &blk|
31
32
  ::Asterism::LOCK.synchronize { super(*args, **kw, &blk) }
32
33
  end
@@ -129,6 +130,21 @@ module Asterism
129
130
  @runner.add { ::Asterism.poll ? 0 : false }
130
131
  end
131
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
+
132
148
  def node_id = ::Asterism.node_id
133
149
  def app = ::Asterism.app
134
150
  def connected? = ::Asterism.connected?
@@ -147,6 +163,17 @@ module Asterism
147
163
  ::Asterism.unexpose(name)
148
164
  end
149
165
 
166
+ # Asterism.profile: this node's profile (with keywords, sets fields).
167
+ def profile(**fields)
168
+ ::Asterism.profile(**fields)
169
+ end
170
+
171
+ # Asterism.profile_of: another node's profile, or nil. Waits like a
172
+ # call (for the receiving thread's ticks while it runs).
173
+ def profile_of(node, icon: true, timeout: nil, timeout_ms: nil)
174
+ ::Asterism.profile_of(node, icon: icon, timeout: timeout, timeout_ms: timeout_ms)
175
+ end
176
+
150
177
  # A proxy for <node>/<app>/<object>; timeout: in seconds (or
151
178
  # timeout_ms:).
152
179
  def [](path, timeout: nil, timeout_ms: nil)
@@ -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
@@ -24,6 +24,44 @@ module Asterism
24
24
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
25
25
  end
26
26
 
27
+ # The profile's defaults on CRuby (mrblib/profile.rb): kind "cruby" and
28
+ # the icon bundled with the gem (replace it with Asterism.profile(icon:)).
29
+ ICON_FILE = File.expand_path("../../data/icon.png", __dir__)
30
+
31
+ def self.default_profile_kind
32
+ "cruby"
33
+ end
34
+
35
+ def self.default_icon_path
36
+ ICON_FILE
37
+ end
38
+
39
+ def self.read_icon_file(path)
40
+ File.binread(path)
41
+ rescue SystemCallError
42
+ nil
43
+ end
44
+
45
+ module Profile
46
+ # Icons come out of MessagePack as UTF-8 Strings: hand them out as the
47
+ # bytes they are.
48
+ class << self
49
+ alias_method :check_portable, :check
50
+
51
+ def check(h)
52
+ out = check_portable(h)
53
+ out["icon"] = out["icon"].b if out&.key?("icon")
54
+ out
55
+ end
56
+
57
+ alias_method :icon_portable!, :icon!
58
+
59
+ def icon!(v)
60
+ icon_portable!(v).b.freeze
61
+ end
62
+ end
63
+ end
64
+
27
65
  def self.error_reply(klass, message)
28
66
  ::MessagePack.pack(["error", klass.to_s, message.to_s], compatibility_mode: true)
29
67
  end
@@ -1,5 +1,6 @@
1
1
  module Asterism
2
- # The gem's version (the CRuby gem asterism; it depends on asterism-zenoh
3
- # of the same version).
4
- VERSION = "0.4.1"
2
+ # The gem's version (the CRuby gem asterism). The Zenoh binding it needs
3
+ # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 and 0.6.0:
4
+ # the bindings did not change; see CHANGELOG.md).
5
+ VERSION = "0.6.0"
5
6
  end
data/lib/asterism.rb CHANGED
@@ -12,7 +12,7 @@ require "asterism/zenoh"
12
12
  require_relative "asterism/version"
13
13
 
14
14
  # The same order as an mrbgem's mrblib (alphabetical).
15
- %w[asterism cdr future proxy ros].each do |f|
15
+ %w[asterism cdr future profile proxy ros].each do |f|
16
16
  require_relative "../mrblib/#{f}"
17
17
  end
18
18
  require_relative "asterism/cruby"
data/mrblib/asterism.rb CHANGED
@@ -14,6 +14,7 @@
14
14
  # asterism/<node>/<app>/<object>/meta get: reply {"methods" => [[name, arity], ...]}
15
15
  # asterism/<node> liveliness: the node is up
16
16
  # asterism/<node>/<app>/<object> liveliness: the object is exposed
17
+ # asterism/<node>/@profile get: reply the node's profile (profile.rb)
17
18
  #
18
19
  # Everything is polled: nothing runs behind the application's back. A call
19
20
  # that waits for its answer keeps polling meanwhile and answers the calls
@@ -221,20 +222,26 @@ module Asterism
221
222
  @node_token = s.liveliness("#{ROOT}/#{node}")
222
223
  @queryable = s.queryable("#{ROOT}/#{node}/#{app}/**", depth: 32)
223
224
  @watch = s.liveliness_watch("#{ROOT}/**", depth: watch_depth)
225
+ @profile_queryable = s.queryable("#{ROOT}/#{node}/#{Profile::KEY}", depth: 4)
224
226
  rescue Asterism::Zenoh::Error => e
225
227
  s.close
226
228
  raise Disconnected, e.message
227
229
  end
230
+ s.extend(SharedSession) # Asterism.session: the application's close does not close it
228
231
  @session = s
229
232
  @node = node
230
233
  @app = app
231
234
  @objects = {} # name => [object, {method name => arity}, token]
232
- @alive = {} # "node/app/object" => true (from liveliness)
235
+ @alive = {} # "node/app/object" => node (from liveliness)
233
236
  @nodes = {} # node => true (from liveliness)
234
237
  @proxies = {}
235
238
  @depth = 0
236
239
  @lost = nil
237
240
  @known = [] # the other nodes told to on_join so far
241
+ @known_set = {} # the same, as node => true
242
+ @node_list = [node] # what nodes returns (this node first), rebuilt on a change
243
+ @nodes_changed = false # liveliness changed since tell_nodes last compared
244
+ tell_profile # for those who follow the profiles (the reply is the queryable's)
238
245
  self
239
246
  end
240
247
 
@@ -295,19 +302,67 @@ module Asterism
295
302
  @objects = {}
296
303
  @alive = {}
297
304
  @nodes = {}
305
+ @node_list = nil
306
+ @nodes_changed = true
298
307
  @proxies = {}
299
- s.close if s
308
+ if s
309
+ @releasing = s
310
+ begin
311
+ s.close
312
+ ensure
313
+ @releasing = nil
314
+ end
315
+ end
300
316
  @node_token = nil
301
317
  @queryable = nil
302
318
  @watch = nil
319
+ @profile_queryable = nil
303
320
  end
304
321
 
305
- # @api private
322
+ # The Zenoh session of the connection (an Asterism::Zenoh::Session), for
323
+ # what the object layer does not do: put and subscribe, a ROS 2 node
324
+ # (Asterism::ROS::Node.new(Asterism.session, ...)), anything of the
325
+ # Zenoh API. One connection for everything: on a router that lets each
326
+ # pass in once, a second session would not get in, and a ROS 2 node on
327
+ # this session is part of this node (the same Zenoh ID).
328
+ #
329
+ # The session stays Asterism's: its close does nothing (it warns once);
330
+ # Asterism.close closes it, and with it everything declared on it (the
331
+ # application's subscribers, publishers, ROS 2 nodes). After a lost
332
+ # connection and a new Asterism.connect, take Asterism.session again and
333
+ # declare those again. Polling it (session.poll, node.poll) is fine:
334
+ # Asterism.poll polls it too, each entity keeps what came for it.
335
+ # Raises Disconnected when not connected (as the calls do).
336
+ def self.session
337
+ raise Disconnected, (@lost || "not connected") unless connected?
338
+ @session
339
+ end
340
+
341
+ # @api private (the name before 0.6.0; Asterism.session is the same)
306
342
  def self.session!
307
343
  raise Disconnected, (@lost || "not connected") unless @session
308
344
  @session
309
345
  end
310
346
 
347
+ # Whether Asterism itself is closing s (SharedSession#close).
348
+ # @api private
349
+ def self.releasing?(s)
350
+ !@releasing.nil? && @releasing.equal?(s)
351
+ end
352
+
353
+ # Extended onto the session of Asterism.connect: the session belongs to
354
+ # Asterism, so a close from anyone else (the application, a library that
355
+ # was handed Asterism.session) does nothing. Asterism.close and a lost
356
+ # connection close it.
357
+ module SharedSession
358
+ def close
359
+ return super if ::Asterism.releasing?(self)
360
+ ::Asterism.warn_once(self, "asterism: Asterism.session belongs to Asterism; its close is ignored " \
361
+ "(Asterism.close closes it)")
362
+ nil
363
+ end
364
+ end
365
+
311
366
  # The connection went away: closed, but the next Asterism.poll still tells
312
367
  # on_leave about every node known until then.
313
368
  # @api private
@@ -357,6 +412,10 @@ module Asterism
357
412
  # Call from the application's update loop. Answers the calls that came
358
413
  # in, follows who is alive and calls the on_join / on_leave blocks. false
359
414
  # once the connection is closed.
415
+ #
416
+ # Cheap when nothing came: each queue is looked at by its count first,
417
+ # and the list of nodes (and the comparison behind on_join / on_leave)
418
+ # is made again only after a liveliness change.
360
419
  def self.poll
361
420
  ok = @session ? pump : false
362
421
  tell_nodes
@@ -404,14 +463,43 @@ module Asterism
404
463
  def self.tell_nodes
405
464
  known = @known
406
465
  return if known.nil?
407
- now = @session ? nodes : []
408
- now.shift if @session # this node
466
+ # Nothing came on the liveliness watch since the last comparison (and
467
+ # the connection is neither new nor lost): nothing can have changed.
468
+ return unless @nodes_changed
469
+ @nodes_changed = false
470
+ now = []
471
+ now_set = {}
472
+ if @session
473
+ list = node_list
474
+ i = 1 # list[0] is this node
475
+ while i < list.size
476
+ n = list[i]
477
+ now << n
478
+ now_set[n] = true
479
+ i += 1
480
+ end
481
+ end
482
+ known_set = @known_set || {}
409
483
  joined = []
410
484
  left = []
411
- now.each { |n| joined << n unless known.include?(n) }
412
- known.each { |n| left << n unless now.include?(n) }
485
+ i = 0
486
+ while i < now.size
487
+ joined << now[i] unless known_set[now[i]]
488
+ i += 1
489
+ end
490
+ i = 0
491
+ while i < known.size
492
+ left << known[i] unless now_set[known[i]]
493
+ i += 1
494
+ end
413
495
  # After a loss, everything has been told: nothing more to compare.
414
- @known = @session ? now : nil
496
+ if @session
497
+ @known = now
498
+ @known_set = now_set
499
+ else
500
+ @known = nil
501
+ @known_set = nil
502
+ end
415
503
  tell_each("on_join", @on_join, joined)
416
504
  tell_each("on_leave", @on_leave, left)
417
505
  end
@@ -447,6 +535,7 @@ module Asterism
447
535
  return false
448
536
  end
449
537
  answer_calls
538
+ answer_profile
450
539
  follow_liveliness
451
540
  !@session.nil?
452
541
  rescue Asterism::Zenoh::Error => e
@@ -488,9 +577,10 @@ module Asterism
488
577
  # @api private
489
578
  def self.follow_liveliness
490
579
  w = @watch
491
- return unless w
580
+ return unless w && w.pending > 0
492
581
  # Array forms instead of blocks called from C (see Future#collect).
493
582
  events = w.each_pending
583
+ @nodes_changed = true if events.size > 0
494
584
  if events.size > 0 && w.dropped > 0
495
585
  warn_once(w, "asterism: #{w.dropped} liveliness changes dropped (more than #{watch_depth} at once); " \
496
586
  "Asterism.each and Asterism.nodes may miss some objects")
@@ -510,24 +600,49 @@ module Asterism
510
600
  end
511
601
  elsif parts.size == 3
512
602
  if alive
513
- @alive[rest] = true
603
+ @alive[rest] = parts[0]
514
604
  else
515
605
  @alive.delete(rest)
516
606
  end
517
607
  end
518
608
  end
609
+ @node_list = nil
519
610
  end
520
611
 
521
612
  # The node IDs alive now, this one first.
522
613
  def self.nodes
523
614
  return [] unless @session
615
+ node_list.dup
616
+ end
617
+
618
+ # The list behind nodes, made again only after a liveliness change (the
619
+ # caller must not change it). @api private
620
+ def self.node_list
621
+ list = @node_list
622
+ return list if list
524
623
  list = [@node]
525
- @nodes.each_key { |n| list << n unless list.include?(n) }
526
- @alive.each_key do |path|
527
- n = path.split("/")[0]
528
- list << n unless list.include?(n)
624
+ seen = { @node => true }
625
+ names = @nodes.keys
626
+ i = 0
627
+ while i < names.size
628
+ n = names[i]
629
+ unless seen[n]
630
+ seen[n] = true
631
+ list << n
632
+ end
633
+ i += 1
529
634
  end
530
- list
635
+ owners = @alive.values
636
+ i = 0
637
+ while i < owners.size
638
+ n = owners[i]
639
+ unless seen[n]
640
+ seen[n] = true
641
+ list << n
642
+ end
643
+ i += 1
644
+ end
645
+ @node_list = list
531
646
  end
532
647
 
533
648
  # The exposed objects alive now whose <node>/<app>/<object> matches the
@@ -538,11 +653,15 @@ module Asterism
538
653
  def self.each(pattern = "**")
539
654
  paths = []
540
655
  if @session
656
+ seen = {}
541
657
  @objects.each_key do |name|
542
658
  path = "#{@node}/#{@app}/#{name}"
543
- paths << path if glob?(pattern, path)
659
+ if glob?(pattern, path)
660
+ paths << path
661
+ seen[path] = true
662
+ end
544
663
  end
545
- @alive.each_key { |path| paths << path if glob?(pattern, path) && !paths.include?(path) }
664
+ @alive.each_key { |path| paths << path if !seen[path] && glob?(pattern, path) }
546
665
  end
547
666
  proxies = paths.map { |path| self[path] }
548
667
  return proxies unless block_given?
@@ -614,7 +733,7 @@ module Asterism
614
733
  # @api private
615
734
  def self.answer_calls
616
735
  qa = @queryable
617
- return unless qa
736
+ return unless qa && qa.pending > 0
618
737
  # Without a block, so that a handler that waits (and so polls) does not
619
738
  # re-enter the queryable's iteration.
620
739
  queries = qa.each_pending
data/mrblib/profile.rb ADDED
@@ -0,0 +1,362 @@
1
+ # A node's profile: what it runs on, where, a display name and an icon,
2
+ # told by the node itself (0.5.0).
3
+ #
4
+ # Asterism.profile(name: "Rover", icon: png) # this node's; returns it
5
+ # Asterism.profile # => {"kind" => "picoruby", "name" => "Rover", ...}
6
+ # Asterism.profile_of("fmruby-aaaaaa") # another node's, or nil
7
+ #
8
+ # Key: asterism/<node>/@profile (a queryable of every application connected
9
+ # as that node). The reply is a MessagePack Hash:
10
+ # "kind" what Ruby runs the node: "family_mruby", "picoruby",
11
+ # "cruby", "console" (others are allowed)
12
+ # "host" where it runs, optional ("atoms3r", "tab5", "sim", ...)
13
+ # "name" a display name, optional
14
+ # "icon" PNG bytes, optional (8 KB and 256x256 at most)
15
+ # "icon_hash" the icon's summary (Profile.png_info): a reader that has
16
+ # that icon already asks again with the parameter icon=0 and
17
+ # gets the rest without it
18
+ # Each connect, and each change while connected, also puts the profile
19
+ # without its icon on the same key, for those who follow it (a console).
20
+ #
21
+ # Room left for later: an application's own profile would be
22
+ # asterism/<node>/<app>/@profile, with the same Hash.
23
+ module Asterism
24
+ module Profile
25
+ # The kinds this version knows. Others pass: a kind is any lower-case
26
+ # word (a-z, 0-9, _; up to KIND_MAX bytes) starting with a letter.
27
+ KINDS = ["family_mruby", "picoruby", "cruby", "console"]
28
+ KEY = "@profile"
29
+ ICON_MAX = 8192 # bytes
30
+ ICON_SIDE_MAX = 256 # pixels, each side
31
+ KIND_MAX = 32
32
+ HOST_MAX = 32
33
+ NAME_MAX = 64
34
+ PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
35
+
36
+ # [width, height, icon_hash] of a PNG that a profile may carry, or nil.
37
+ # Checks the signature, the size (ICON_MAX bytes), that the chunks tile
38
+ # the bytes from IHDR to IEND, and the image's sides (ICON_SIDE_MAX).
39
+ #
40
+ # icon_hash is the Adler-32 (8 hex digits) of the chunks' CRC-32 fields,
41
+ # in order: each CRC already sums up its chunk, so the summary costs a
42
+ # few bytes of arithmetic instead of a pass over the whole file (small
43
+ # integers only, the same on every Ruby). On CRuby it equals
44
+ # Zlib.adler32 of those fields joined.
45
+ def self.png_info(b)
46
+ return nil unless b.is_a?(String)
47
+ size = b.bytesize
48
+ return nil if size < 8 + 25 + 12 || size > ICON_MAX
49
+ i = 0
50
+ while i < 8
51
+ return nil unless b.getbyte(i) == PNG_SIGNATURE[i]
52
+ i += 1
53
+ end
54
+ pos = 8
55
+ first = true
56
+ w = 0
57
+ h = 0
58
+ a = 1
59
+ s = 0
60
+ while pos + 12 <= size
61
+ # The length is 4 bytes; anything past 16 bits is larger than ICON_MAX.
62
+ return nil unless b.getbyte(pos) == 0 && b.getbyte(pos + 1) == 0
63
+ len = b.getbyte(pos + 2) * 256 + b.getbyte(pos + 3)
64
+ nxt = pos + 12 + len
65
+ return nil if nxt > size
66
+ if first
67
+ return nil unless type?(b, pos, 73, 72, 68, 82) && len == 13 # IHDR
68
+ return nil unless b.getbyte(pos + 8) == 0 && b.getbyte(pos + 9) == 0
69
+ return nil unless b.getbyte(pos + 12) == 0 && b.getbyte(pos + 13) == 0
70
+ w = b.getbyte(pos + 10) * 256 + b.getbyte(pos + 11)
71
+ h = b.getbyte(pos + 14) * 256 + b.getbyte(pos + 15)
72
+ return nil if w < 1 || h < 1 || w > ICON_SIDE_MAX || h > ICON_SIDE_MAX
73
+ first = false
74
+ end
75
+ k = nxt - 4
76
+ while k < nxt
77
+ a = (a + b.getbyte(k)) % 65521
78
+ s = (s + a) % 65521
79
+ k += 1
80
+ end
81
+ if type?(b, pos, 73, 69, 78, 68) # IEND
82
+ return nil unless nxt == size
83
+ return [w, h, hex4(s) + hex4(a)]
84
+ end
85
+ pos = nxt
86
+ end
87
+ nil
88
+ end
89
+
90
+ # @api private
91
+ def self.type?(b, pos, c0, c1, c2, c3)
92
+ b.getbyte(pos + 4) == c0 && b.getbyte(pos + 5) == c1 && b.getbyte(pos + 6) == c2 && b.getbyte(pos + 7) == c3
93
+ end
94
+
95
+ # @api private
96
+ def self.hex4(n)
97
+ t = n.to_s(16)
98
+ t = "0" + t while t.length < 4
99
+ t
100
+ end
101
+
102
+ # A received profile, cleaned: the known fields that pass their checks
103
+ # (a field that does not is left out; an icon that is not a valid PNG
104
+ # goes with its hash). nil when it is not a profile (no valid kind).
105
+ def self.check(h)
106
+ return nil unless h.is_a?(Hash)
107
+ kind = h["kind"]
108
+ return nil unless kind.is_a?(String) && kind?(kind)
109
+ out = { "kind" => kind }
110
+ host = h["host"]
111
+ out["host"] = host if host.is_a?(String) && !host.empty? && host.bytesize <= HOST_MAX
112
+ name = h["name"]
113
+ out["name"] = name if name.is_a?(String) && !name.empty? && name.bytesize <= NAME_MAX
114
+ icon = h["icon"]
115
+ if icon.is_a?(String)
116
+ info = png_info(icon)
117
+ if info
118
+ out["icon"] = icon
119
+ out["icon_hash"] = info[2]
120
+ end
121
+ elsif hash_text?(h["icon_hash"])
122
+ out["icon_hash"] = h["icon_hash"]
123
+ end
124
+ out
125
+ end
126
+
127
+ # @api private
128
+ def self.kind?(s)
129
+ n = s.bytesize
130
+ return false if n < 1 || n > KIND_MAX
131
+ i = 0
132
+ while i < n
133
+ c = s.getbyte(i)
134
+ lower = c >= 97 && c <= 122
135
+ return false unless lower || (i > 0 && ((c >= 48 && c <= 57) || c == 95))
136
+ i += 1
137
+ end
138
+ true
139
+ end
140
+
141
+ # @api private
142
+ def self.hash_text?(s)
143
+ return false unless s.is_a?(String) && s.bytesize == 8
144
+ i = 0
145
+ while i < 8
146
+ c = s.getbyte(i)
147
+ return false unless (c >= 48 && c <= 57) || (c >= 97 && c <= 102)
148
+ i += 1
149
+ end
150
+ true
151
+ end
152
+
153
+ # The checks of Asterism.profile (ArgumentError, with the reason).
154
+ # @api private
155
+ def self.kind!(v)
156
+ s = v.is_a?(Symbol) ? v.to_s : v
157
+ unless s.is_a?(String) && kind?(s)
158
+ raise ArgumentError, "profile kind: #{v.inspect} is not a lower-case word (a-z, 0-9, _; " \
159
+ "#{KIND_MAX} bytes at most), e.g. :cruby"
160
+ end
161
+ s
162
+ end
163
+
164
+ # @api private
165
+ def self.text!(what, v, max)
166
+ s = v.is_a?(Symbol) ? v.to_s : v
167
+ raise ArgumentError, "profile #{what}: expected a String, got #{v.class}" unless s.is_a?(String)
168
+ raise ArgumentError, "profile #{what}: empty (pass nil to leave it out)" if s.empty?
169
+ raise ArgumentError, "profile #{what}: #{s.bytesize} bytes (#{max} at most)" if s.bytesize > max
170
+ s
171
+ end
172
+
173
+ # @api private
174
+ def self.icon!(v)
175
+ raise ArgumentError, "profile icon: expected the bytes of a PNG (a String), got #{v.class}" unless v.is_a?(String)
176
+ if v.bytesize > ICON_MAX
177
+ raise ArgumentError, "profile icon: #{v.bytesize} bytes (#{ICON_MAX} at most)"
178
+ end
179
+ unless png_info(v)
180
+ raise ArgumentError, "profile icon: not a PNG, or not one this accepts (up to " \
181
+ "#{ICON_SIDE_MAX}x#{ICON_SIDE_MAX}, chunks from IHDR to IEND)"
182
+ end
183
+ v
184
+ end
185
+
186
+ # Whether a query's parameters ask for the icon (icon=0 says no).
187
+ # @api private
188
+ def self.wants_icon?(params)
189
+ return true if params.nil? || params.empty?
190
+ params.split(";").each do |pair|
191
+ kv = pair.split("=")
192
+ next unless kv[0] == "icon"
193
+ v = kv[1].to_s
194
+ return false if v == "0" || v == "no" || v == "false"
195
+ end
196
+ true
197
+ end
198
+ end
199
+
200
+ # This node's profile. With keywords, sets those fields and returns the
201
+ # profile; without, returns it. The fields: kind: (a Symbol or String;
202
+ # nil goes back to the default), name:, host: (Strings; nil leaves them
203
+ # out), icon: (the bytes of a PNG; nil for no icon). The fields not given
204
+ # stay as they are. Raises ArgumentError for a field it does not accept
205
+ # (see Asterism::Profile).
206
+ #
207
+ # The defaults: kind "picoruby" on mruby / PicoRuby, "cruby" on CRuby; no
208
+ # host, no name; the icon is the file at Asterism.default_icon_path when
209
+ # there is one (the boards: /usr/share/asterism/icon.png; CRuby: the gem's
210
+ # data/icon.png). A board's firmware may set its own defaults with this
211
+ # same call before the application runs (Family mruby does).
212
+ #
213
+ # Kept across connect and close (it belongs to the process, or the VM on
214
+ # a board). While connected, a change is also put on the profile's key.
215
+ def self.profile(**fields)
216
+ return profile_hash(true) if fields.empty?
217
+ changes = {}
218
+ fields.each do |k, v|
219
+ key = k.to_s
220
+ if key == "kind"
221
+ changes[key] = v.nil? ? nil : Profile.kind!(v)
222
+ elsif key == "name"
223
+ changes[key] = v.nil? ? nil : Profile.text!("name", v, Profile::NAME_MAX)
224
+ elsif key == "host"
225
+ changes[key] = v.nil? ? nil : Profile.text!("host", v, Profile::HOST_MAX)
226
+ elsif key == "icon"
227
+ changes[key] = v.nil? ? nil : Profile.icon!(v)
228
+ else
229
+ raise ArgumentError, "unknown profile field #{key} (kind, name, host, icon)"
230
+ end
231
+ end
232
+ set = (@profile_set ||= {})
233
+ changes.each do |k, v|
234
+ if v.nil? && k != "icon"
235
+ set.delete(k)
236
+ else
237
+ set[k] = v # icon: nil stays, so the default file is not used
238
+ end
239
+ end
240
+ @profile_icon = nil if changes.key?("icon")
241
+ tell_profile
242
+ profile_hash(true)
243
+ end
244
+
245
+ # Another node's profile (Asterism::Profile.check has cleaned it), or nil
246
+ # when it does not answer in time (an older version of Asterism, a ROS 2
247
+ # node, a node that is gone). icon: false asks for it without the icon
248
+ # bytes (icon_hash still says which icon it is). timeout: in seconds (or
249
+ # timeout_ms:), 2.0 by default. Waits like a call (polling, answering
250
+ # calls meanwhile); raises Disconnected when not connected. This node's
251
+ # own profile comes without the network.
252
+ def self.profile_of(node, icon: true, timeout: nil, timeout_ms: nil)
253
+ f = profile_async(node, icon, timeout, timeout_ms)
254
+ begin
255
+ raw = f.raw_value
256
+ rescue Disconnected
257
+ raise
258
+ rescue Error
259
+ return nil # no answer in time, or not MessagePack
260
+ end
261
+ Profile.check(raw)
262
+ end
263
+
264
+ # @api private
265
+ def self.profile_async(node, icon, timeout, timeout_ms)
266
+ node = check_name("node", node)
267
+ ms = time_ms("Asterism.profile_of", timeout, timeout_ms, nil, DEFAULT_TIMEOUT_MS)
268
+ path = "#{node}/#{Profile::KEY}"
269
+ return Future.answered(path, "profile", Codec.pack(profile_hash(icon))) if @session && node == @node
270
+ begin
271
+ g = session!.get("#{ROOT}/#{path}", timeout_ms: ms, params: icon ? nil : "icon=0")
272
+ rescue Asterism::Zenoh::Error => e
273
+ lost(e.message)
274
+ raise Disconnected, e.message
275
+ end
276
+ Future.new(path, "profile", g, now_ms + ms, ms)
277
+ end
278
+
279
+ # The kind when the application did not set one.
280
+ # @api private
281
+ def self.default_profile_kind
282
+ "picoruby"
283
+ end
284
+
285
+ # Where the default icon is read from (when the application gave none);
286
+ # nil for none. CRuby: the gem's data/icon.png.
287
+ def self.default_icon_path
288
+ "/usr/share/asterism/icon.png"
289
+ end
290
+
291
+ # The bytes of a file, nil when it cannot be read.
292
+ # @api private
293
+ def self.read_icon_file(path)
294
+ return nil unless Object.const_defined?(:File) && File.exist?(path)
295
+ File.open(path, "r") { |f| f.read }
296
+ rescue
297
+ nil
298
+ end
299
+
300
+ # The profile as sent: String keys, the icon only when with_icon.
301
+ # @api private
302
+ def self.profile_hash(with_icon)
303
+ set = @profile_set || {}
304
+ h = { "kind" => set["kind"] || default_profile_kind }
305
+ h["host"] = set["host"] if set["host"]
306
+ h["name"] = set["name"] if set["name"]
307
+ icon = profile_icon
308
+ if icon
309
+ h["icon"] = icon[0] if with_icon
310
+ h["icon_hash"] = icon[1]
311
+ end
312
+ h
313
+ end
314
+
315
+ # [bytes, icon_hash] of this node's icon, or nil. The default file is
316
+ # read once, when the profile is first needed.
317
+ # @api private
318
+ def self.profile_icon
319
+ if @profile_icon.nil?
320
+ set = @profile_set || {}
321
+ bytes = set.key?("icon") ? set["icon"] : nil
322
+ unless set.key?("icon")
323
+ path = default_icon_path
324
+ bytes = path ? read_icon_file(path) : nil
325
+ end
326
+ info = bytes ? Profile.png_info(bytes) : nil
327
+ @profile_icon = info ? [bytes, info[2]] : false
328
+ end
329
+ @profile_icon ? @profile_icon : nil
330
+ end
331
+
332
+ # Puts the profile without its icon on its key (while connected).
333
+ # @api private
334
+ def self.tell_profile
335
+ s = @session
336
+ return unless s
337
+ s.put("#{ROOT}/#{@node}/#{Profile::KEY}", Codec.pack(profile_hash(false)))
338
+ rescue Asterism::Zenoh::Error
339
+ nil # the next poll notices a lost connection
340
+ end
341
+
342
+ # @api private
343
+ def self.answer_profile
344
+ qa = @profile_queryable
345
+ return unless qa && qa.pending > 0
346
+ queries = qa.each_pending
347
+ i = 0
348
+ while i < queries.size
349
+ q = queries[i]
350
+ i += 1
351
+ begin
352
+ q.reply("#{ROOT}/#{@node}/#{Profile::KEY}", Codec.pack(profile_hash(Profile.wants_icon?(q.params))))
353
+ ensure
354
+ begin
355
+ q.finish
356
+ rescue Asterism::Zenoh::Error
357
+ # the session went away meanwhile
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-09 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: asterism-zenoh
@@ -50,6 +50,7 @@ files:
50
50
  - LICENSE
51
51
  - NOTICE
52
52
  - README.md
53
+ - data/icon.png
53
54
  - data/msgs/LICENSE-Apache-2.0.txt
54
55
  - data/msgs/LICENSE-BSD-3-Clause-tf2_msgs.txt
55
56
  - data/msgs/NOTICE
@@ -147,6 +148,7 @@ files:
147
148
  - mrblib/asterism.rb
148
149
  - mrblib/cdr.rb
149
150
  - mrblib/future.rb
151
+ - mrblib/profile.rb
150
152
  - mrblib/proxy.rb
151
153
  - mrblib/ros.rb
152
154
  - tools/asterism_msggen.rb