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 +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +153 -8
- data/data/icon.png +0 -0
- data/lib/asterism/cruby/objects.rb +28 -1
- data/lib/asterism/cruby/ros.rb +16 -4
- data/lib/asterism/cruby/zenoh.rb +32 -6
- data/lib/asterism/cruby.rb +38 -0
- data/lib/asterism/version.rb +4 -3
- data/lib/asterism.rb +1 -1
- data/mrblib/asterism.rb +137 -18
- data/mrblib/profile.rb +362 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba14899dd37f9c4c21e148a2256d71545785da6f61a1f8f6701e2d0d189e9061
|
|
4
|
+
data.tar.gz: 207e08ed177ffa93cd5c7173a84c31f519f59c8e076c6c641ccaa8db27712ea9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| `
|
|
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.
|
|
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>/**`)
|
|
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/
|
|
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
|
|
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)
|
data/lib/asterism/cruby/ros.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/asterism/cruby/zenoh.rb
CHANGED
|
@@ -158,11 +158,29 @@ module Asterism
|
|
|
158
158
|
class Connection
|
|
159
159
|
attr_reader :session, :runner
|
|
160
160
|
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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}#{
|
|
406
|
+
"#<Asterism::Zenoh::Connection #{closed? ? 'closed' : zid}#{@shared ? ' shared' : ''}" \
|
|
407
|
+
"#{running? ? ' running' : ''}>"
|
|
382
408
|
end
|
|
383
409
|
|
|
384
410
|
private
|
data/lib/asterism/cruby.rb
CHANGED
|
@@ -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
|
data/lib/asterism/version.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module Asterism
|
|
2
|
-
# The gem's version (the CRuby gem asterism
|
|
3
|
-
#
|
|
4
|
-
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
526
|
-
@
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
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-
|
|
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
|