asterism 0.1.0 → 0.3.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: 16a71827f6f79aa024b98f6aa686efa127228644d12f6bef3469dad98d819dac
4
- data.tar.gz: b564b747d9cb0dd6d890e86e95cbc04ca75f7d1a55b24490bfd315edfc1cf25a
3
+ metadata.gz: f03c6be1c5461da008a067c55b609a009b31b7552810836b7272bcdd35443ba7
4
+ data.tar.gz: df8c8641c8039408dcb3ca803b7fdcfe0a24e3eef3199da0b1277e980977f936
5
5
  SHA512:
6
- metadata.gz: bff73ce315295e1d2a4426eef3deda857ba8c77db3c789cb48b0ec489c101cd35633fb6d73fbba31d641ad9d467bbaa7db80aefcf4686af1a798a9f4719a66c4
7
- data.tar.gz: 594b310948679e9471dab6853846692c0ffc94a670456b9fcccfe44f7c0e4fbc3cf90fde19e401f992e31897b2b385f7a1856c6a8bef5920b1b6cf75c868e7bf
6
+ metadata.gz: 663fbb3e79d9b824128b541b899028809d899916af70a28fe761581fa2e28285f9841ef78dfafd088c2583f2c9df705328566b1f465ec31886df277c013c90a2
7
+ data.tar.gz: d447307b7bba1387d35316044c483f974a814ab1b0a4c7f5d32e17e8fc0b954ed2388ee53aaef9bf780bb7ccb98a93669fdf30f0efb7357d007535cae26a33ec
data/README.md CHANGED
@@ -9,11 +9,11 @@ repository holds the pure Ruby layers, written once for every Ruby:
9
9
  |---|---|
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
- | `asterism.gemspec`, `lib/` | The CRuby gem `asterism`: `lib/asterism.rb` loads `mrblib/` as it is, `lib/asterism/cruby.rb` adds what CRuby needs |
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 Ruby-like API (CRuby only) |
13
13
  | `data/msgs/` | ROS 2 message types generated from ROS 2 Jazzy's definitions (Apache-2.0, see NOTICE) |
14
14
  | `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) |
16
- | `examples/` | CRuby: `node.rb` (objects with a board), `ros2_talker.rb` (Twist and AddTwoInts with ROS 2) |
15
+ | `test/` | `test/msgs` (types, CDR; CRuby only), `test_asterism.rb` (objects and ROS between CRuby sessions), `test_api_*.rb` (the Ruby-like API) |
16
+ | `examples/` | CRuby: `node.rb` (objects with a board), `node_polled.rb` (the same with the polled API of the boards), `ros2_talker.rb` (topics, a timer, services with ROS 2), `zenoh.rb` (plain Zenoh) |
17
17
 
18
18
  The Zenoh binding underneath is a separate gem with the same Ruby API on
19
19
  each Ruby:
@@ -23,7 +23,15 @@ each Ruby:
23
23
  - mruby / PicoRuby: [picoruby-asterism-zenoh](https://github.com/ruby-asterism/picoruby-asterism-zenoh)
24
24
  (over zenoh-pico)
25
25
 
26
- Neither gem is on rubygems.org yet (version 0.1.0 builds from these
26
+ Which zenoh-c features the CRuby binding exposes (configuration and TLS,
27
+ scouting, publishers, queriers, the advanced publisher / subscriber,
28
+ events, key expressions, timestamps) and which zenoh-pico could offer is
29
+ in asterism-zenoh's
30
+ [feature coverage](https://github.com/ruby-asterism/asterism-zenoh/blob/main/docs/feature_coverage.md).
31
+ The ones added in 0.3.0 are CRuby only: the shared layer (`mrblib/`) does
32
+ not use them.
33
+
34
+ Neither gem is on rubygems.org yet (version 0.3.0 builds from these
27
35
  repositories with `rake gem`).
28
36
 
29
37
  ## Using it
@@ -31,7 +39,7 @@ repositories with `rake gem`).
31
39
  CRuby (needs CRuby 3.2+ and a C compiler):
32
40
 
33
41
  ```
34
- gem install asterism # also installs asterism-zenoh (~> 0.1.0) and msgpack
42
+ gem install asterism # also installs asterism-zenoh (~> 0.3.0) and msgpack
35
43
  ruby -e 'require "asterism"'
36
44
  ```
37
45
 
@@ -44,7 +52,7 @@ build but not yet tested. To install from the repositories instead:
44
52
 
45
53
  ```
46
54
  (cd ../asterism-zenoh && rake gem) && rake gem
47
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.1.0.gem pkg/asterism-0.1.0.gem
55
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.3.0.gem pkg/asterism-0.3.0.gem
48
56
  ```
49
57
 
50
58
  From the working trees, without installing:
@@ -67,13 +75,14 @@ these repositories.
67
75
  ## Tests
68
76
 
69
77
  ```
70
- rake # both suites
78
+ rake # every suite
71
79
  rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only; no Zenoh, no docker)
72
80
  rake test:objects # objects and Asterism::ROS between CRuby sessions over a local peer link
73
- ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects # the same through a zenohd router
81
+ rake test:api # the Ruby-like API (blocks, receiving threads, Enumerators)
82
+ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
74
83
  ```
75
84
 
76
- `test:objects` needs asterism-zenoh: built next to this repository
85
+ `test:objects` and `test:api` need asterism-zenoh: built next to this repository
77
86
  (`../asterism-zenoh`, its `rake compile`), or `ASTERISM_ZENOH_DIR`, or the
78
87
  installed gem.
79
88
 
@@ -83,11 +92,172 @@ installed gem.
83
92
  # objects: join the app "demo" of Family mruby's asterism_demo, call a board,
84
93
  # and answer its calls (its keys s / a call screen.say / apu.play here)
85
94
  ruby examples/node.rb --router tcp/192.0.2.2:7447 [--peer fmruby-aaaaaa]
95
+ ruby examples/node_polled.rb --router tcp/192.0.2.2:7447 # the same, polled
86
96
 
87
- # ROS 2: geometry_msgs/Twist on /cmd_vel, then AddTwoInts calls
97
+ # ROS 2: /chatter and /cmd_vel from a timer, /chatter_in printed,
98
+ # /cruby/add_two_ints served, AddTwoInts calls
88
99
  ruby examples/ros2_talker.rb --router tcp/192.0.2.2:7447 [--service NAME]
100
+
101
+ # plain Zenoh: run two with different --name
102
+ ruby examples/zenoh.rb --router tcp/192.0.2.2:7447 --name pc1
103
+ ```
104
+
105
+ ## The Ruby-like API (CRuby only)
106
+
107
+ On CRuby, `require "asterism"` also brings a layer with blocks, a receiving
108
+ thread, Enumerators and pattern matching (`lib/asterism/cruby/`). It is
109
+ built on the polled API below, which stays as it is: code written for a
110
+ board still runs unchanged on CRuby, and code using this layer does not run
111
+ on a board.
112
+
113
+ ### Zenoh
114
+
115
+ ```ruby
116
+ Asterism::Zenoh.open("tcp/192.0.2.2:7447") do |s| # closed when the block ends
117
+ s.subscribe("home/**") { |sample| puts "#{sample.key} = #{sample.payload}" }
118
+ s.queryable("home/pc/status") { |q| q.reply("ok") }
119
+ s.put("home/pc/hello", "hi")
120
+ s.get("home/**/status").each { |reply| p reply } # until every answer came or the time ran out
121
+ sub = s.subscribe("sensor/temp") # no block: take from it
122
+ sub.each.lazy.map { _1.payload.to_f }.first(3) # waits for the samples
123
+ s.liveliness_watch("asterism/**") { |key, alive| puts "#{key} #{alive}" }
124
+ s.run # receive on this thread until Ctrl-C
125
+ end
126
+
127
+ # asterism-zenoh 0.3.0 (CRuby only)
128
+ Asterism::Zenoh.open("tls/192.0.2.2:7447",
129
+ config: { "transport/link/tls/root_ca_certificate" => "ca.pem" }) do |s|
130
+ pub = s.publisher("home/pc/temp", encoding: "text/plain", priority: :data_high)
131
+ pub.on_matching { |listening| puts "listened to: #{listening}" }
132
+ pub.put(21.5)
133
+ s.delete("home/pc/old")
134
+ s.subscribe("home/**") { |sm| p [sm.kind, sm.encoding, sm.timestamp&.to_time] }
135
+ q = s.querier("home/**/status", timeout: 1.0)
136
+ q.get(errors: true).each { |r| puts r.error? ? "error: #{r.payload}" : r.payload }
137
+ latched = s.advanced_publisher("home/pc/mode", cache: 1, sample_miss_detection: true)
138
+ latched.put("eco") # late subscribers with history: get it
139
+ s.advanced_subscriber("home/**/mode", history: true) { |sm| puts sm.payload }
140
+ s.on_transport { |ev| puts "#{ev.zid} #{ev.kind}" }
141
+ s.run
142
+ end
143
+ ```
144
+
145
+ | Call | Notes |
146
+ |---|---|
147
+ | `Asterism::Zenoh.open(locator = nil, mode:, listen:, scouting:, timestamping:, config:, config_file:, interval: 0.002)` | `Session.open` wrapped in a `Connection`. With a block: yields it, closes it after (also on an exception), returns the block's value |
148
+ | `Connection.new(session)` | wraps a session opened with the polled API |
149
+ | `s.put(key, payload, attachment: nil, **opts)` | a payload that is not a String is sent as `to_s`. opts: `encoding:`, `priority:`, `congestion_control:`, `express:`, `reliability:`, `timestamp:`, `allowed_destination:` (asterism-zenoh's `Session#put`) |
150
+ | `s.delete(key, **opts)` | subscribers get a `Sample` of kind `:delete` |
151
+ | `s.publisher(key, **opts)` | a `Publisher`: `put(payload, attachment:, encoding:, timestamp:)`, `delete`, `matching?`, `on_matching { \|listening\| }` (on the receiving thread), `close` |
152
+ | `s.advanced_publisher(key, cache:, sample_miss_detection:, publisher_detection:, **opts)` | the same `Publisher`, keeping the last samples for late subscribers (ROS 2's transient local) |
153
+ | `s.advanced_subscriber(key, depth: 16, history:, recovery:, **opts) { \|sample\| }` | a `Subscription` that also gets the publishers' history; `on_publisher { \|key, alive\| }`, `on_miss { \|miss\| }` |
154
+ | `s.querier(key, timeout: 2.0, **opts)` | a `Querier`: `get(params:, payload:, attachment:, encoding:, errors: false)` like `s.get`, `matching?`, `on_matching`, `close` |
155
+ | `s.on_transport(history: false) { \|ev\| }` / `s.on_link { \|ev\| }` | a `TransportEvent` / `LinkEvent` (`added?` / `removed?`, `zid`, ...) on the receiving thread; returns an `Events` (`close`) |
156
+ | `s.peer_zids`, `router_zids`, `transports`, `links`, `new_timestamp`, `declare_keyexpr(key)` | as in asterism-zenoh |
157
+ | `s.subscribe(key, depth: 16) { \|sample\| }` | the block gets each `Sample` on the receiving thread. Returns a `Subscription` (`close` ends it) |
158
+ | `s.subscribe(key, depth: 16)` | a `Subscription` to take from: `each(timeout: nil)` waits for samples (an Enumerator without a block; Enumerable), `each_pending` gives what is there now as Samples, `pending` / `received` / `dropped` / `closed?` / `close` |
159
+ | `s.queryable(key, depth: 16, complete: false) { \|q\| }` | each query on the receiving thread, finished when the block returns. `q.reply(payload)` answers on the queryable's key (when it has no wildcard), `q.reply(key, payload, attachment:)` as before. Without a block, `each(timeout:)` yields the queries |
160
+ | `s.get(key, timeout: 2.0, params:, payload:, attachment:, target:, consolidation:, errors: false, **opts)` | an Enumerator of `Reply`; each iteration sends the get again. With a block: yields each, returns their number. `errors: true` also yields the error replies (`reply.error?`); opts: `encoding:`, `priority:`, `congestion_control:`, `express:`, `accept_replies:` |
161
+ | `s.liveliness(key)` | a token (`close`) |
162
+ | `s.liveliness_watch(key) { \|key, alive\| }` | on the receiving thread; the tokens alive now come first. Without a block, `each` yields `Liveliness` (`key`, `alive?`) |
163
+ | `s.liveliness_get(key, timeout: 2.0)` | the keys alive now (an Array) |
164
+ | `s.start` / `s.stop` / `s.running?` | receive on a thread of its own |
165
+ | `s.run` | receive on this thread until `stop`, the connection closing, or Ctrl-C (returns nil) |
166
+ | `s.on_error { \|error, where\| }` | what the blocks raise (`StandardError`); without a handler it is printed with `warn`. Receiving goes on |
167
+ | `s.session`, `zid`, `peers`, `poll`, `closed?`, `close` | |
168
+
169
+ `Sample`, `Reply` (`key`, `payload`, `attachment`, and since 0.3.0 `kind`,
170
+ `encoding`, `timestamp` and the rest; `text` is the payload as UTF-8; both
171
+ are asterism-zenoh's) and `Liveliness` are `Data` values, so they work with pattern
172
+ matching: `case sample in {key: %r{/temp\z}, payload:}`. Times are seconds
173
+ here (the polled API counts milliseconds).
174
+
175
+ ### Objects
176
+
177
+ ```ruby
178
+ Asterism.connect("tcp/192.0.2.2:7447", node: "mypc", app: "demo") do |net| # closed after
179
+ net.expose("screen", Screen.new, methods: [:say])
180
+ net.on_join { |node| puts "joined #{node}" }
181
+ net.on_leave { |node| puts "left #{node}" }
182
+ board = net["fmruby-aaaaaa/demo/screen"]
183
+ board.say("hello")
184
+ net.each("*/demo/info").map(&:status) # Enumerable
185
+ net.run # answer calls until Ctrl-C
186
+ end
89
187
  ```
90
188
 
189
+ | Call | Notes |
190
+ |---|---|
191
+ | `Asterism.connect(...) { \|net\| }` | yields an `Asterism::Net`, `Asterism.close` when the block ends; returns the block's value. Without a block, as before |
192
+ | `Asterism.net` | the `Net` of the connection made without a block (nil when not connected) |
193
+ | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose`. The methods run on the receiving thread, in `run`, or in whatever polls |
194
+ | `net[path, timeout: nil]` | a proxy (`timeout` in seconds) |
195
+ | `net.each(pattern = "**")` | the proxies alive now; an Enumerator without a block |
196
+ | `net.on_join { \|node\| }` / `net.on_leave { \|node\| }` | `Asterism.on_join` / `on_leave` of the polled API (the same blocks, told from `Asterism.poll`), here on the receiving thread; what they raise goes to `on_error` |
197
+ | `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 |
198
+ | `net.nodes`, `node_id`, `app`, `connected?`, `lost_reason`, `poll`, `close` | |
199
+
200
+ ### ROS 2
201
+
202
+ ```ruby
203
+ Asterism::ROS.connect("tcp/192.0.2.2:7447", domain: 0) do |ros|
204
+ node = ros.node("ruby_talker")
205
+ chatter = node.publisher("/chatter", "std_msgs/msg/String")
206
+ chatter << { data: "hello from Ruby" }
207
+ node.subscribe("/odom", "nav_msgs/msg/Odometry") { |odom| puts odom.pose.pose.position.x }
208
+ node.every(0.1) { chatter << { data: Time.now.to_s } }
209
+ node.service("/add_ruby", "example_interfaces/srv/AddTwoInts") { |req| { sum: req.a + req.b } }
210
+ p node.call("/add_two_ints", "example_interfaces/srv/AddTwoInts", a: 1, b: 2).sum
211
+ node.topic("/scan", "sensor_msgs/msg/LaserScan").each.lazy.first(1)
212
+ ros.spin # until Ctrl-C
213
+ end
214
+ ```
215
+
216
+ | Call | Notes |
217
+ |---|---|
218
+ | `Asterism::ROS.connect(locator = nil, domain: 0, type_path: nil, mode:, listen:)` | opens a session; yields an `Asterism::ROS::Connection` and closes it after. `type_path:` adds directories to `TYPE_PATH` |
219
+ | `ros.node(name, namespace: "/", enclave: "/")` | an `Asterism::ROS::Node` (every method of the polled API) with the calls below |
220
+ | `node.subscribe(topic, type) { \|msg, info\| }` | the polled API's (run from `node.poll`), here on the receiving thread; `info` is the `Attachment` or nil. Without a block: a subscription whose `each(timeout: nil)` waits for `[msg, info]` (Enumerable) |
221
+ | `node.topic(topic, type).each` | subscribes for the iteration and withdraws after it (`first(n)`, `break`, the time running out) |
222
+ | `node.every(seconds) { }` | the polled API's timer (run from `node.poll`), here on the receiving thread; returns a `Timer` (`cancel`, `fired`) |
223
+ | `node.service(name, type) { \|req\| response }` | answered while the connection spins (or from `node.poll`). What the block raises goes to `on_error`, and that request gets no answer |
224
+ | `node.call(name, type, request = nil, timeout: 2.0, **fields)` | waits for the response |
225
+ | `ros.spin` (`run`) / `start` / `stop` / `running?` / `on_error` / `close` | |
226
+ | `ros.zenoh` | the `Asterism::Zenoh::Connection` underneath (same receiving thread) |
227
+
228
+ `type` is a generated type or its name (`"geometry_msgs/msg/Twist"`).
229
+ Messages and `Attachment` take part in pattern matching (`deconstruct_keys`,
230
+ on the boards too): `case msg in {linear: {x:}, angular: {z:}}`. A type
231
+ that is not bundled: generate it with `tools/asterism_msggen.rb -o DIR`
232
+ (see [Message types](#message-types)) and give the directory, as
233
+ `Asterism::ROS.connect(..., type_path: "DIR")` or
234
+ `Asterism::ROS::TYPE_PATH << "DIR"`; then pass its name like any other.
235
+
236
+ ### Threads
237
+
238
+ - Each connection has at most one receiving thread, and only when the
239
+ application asks for it (`start`), or the application's own thread
240
+ inside `run` / `spin`. The blocks (subscriptions, queryables, timers,
241
+ services, exposed objects, `on_join`) run there, one after another. **A
242
+ block that takes long delays everything else that connection receives**:
243
+ hand long work to a thread or a Queue of your own.
244
+ - zenoh-c never calls Ruby: it fills the queues, and the receiving thread
245
+ empties them every 2 ms (`interval:`) with the polled API.
246
+ - A tick of the receiving thread holds the connection's lock (a `Monitor`;
247
+ for the object layer, which is one connection per process,
248
+ `Asterism::LOCK`). The application's calls into the same layer take it
249
+ too, so the layers written for one thread are never entered by two at
250
+ once.
251
+ - A call that waits for an answer (`proxy.method`, `node.call`) from another
252
+ thread while the receiving thread runs does not poll by itself: it waits
253
+ until a tick brings the answer. On the receiving thread itself (a block
254
+ that calls), or while nothing receives, it polls as on the boards.
255
+ - `stop`, `close` and the end of the block wait for the receiving thread to
256
+ end; no thread is left behind. When the connection is lost the receiving
257
+ thread ends by itself, and waiting calls raise as before.
258
+ - Enumerators (`each`, `get`, `topic`) wait on the thread that iterates
259
+ them.
260
+
91
261
  ## License
92
262
 
93
263
  MIT (LICENSE) for Asterism's code. The ROS 2 definitions in
@@ -108,7 +278,8 @@ RubyGems, and on mruby / PicoRuby the Zenoh binding is the separate mrbgem
108
278
 
109
279
  # The API
110
280
 
111
- The same on every Ruby. The examples use PicoRuby's `sleep_ms`; on CRuby
281
+ The same on every Ruby (the polled API; CRuby adds the layer described in
282
+ [The Ruby-like API](#the-ruby-like-api-cruby-only)). The examples use PicoRuby's `sleep_ms`; on CRuby
112
283
  write `sleep`. When PicoRuby's `Machine` is there it is used for the clock
113
284
  and the short pauses while waiting; otherwise `Time` and `sleep`. Besides
114
285
  `Asterism::Zenoh`, the layers need a `MessagePack` module with `pack` /
@@ -138,7 +309,8 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
138
309
  | `Asterism.connect(locator, node:, app:, mode: nil, listen: nil)` | `Asterism` | `locator`, `mode:`, `listen:` 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 about 1.5 s for that check (a router answers at once). |
139
310
  | `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. |
140
311
  | `Asterism.unexpose(name)` | true / false | |
141
- | `Asterism.poll` | true / false | Call from the update loop: polls Zenoh, answers the calls that came in, follows who is alive. `false` once the connection is closed or lost. |
312
+ | `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. |
313
+ | `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`. `Asterism.close` forgets them (a lost connection does not). |
142
314
  | `Asterism[path, timeout_ms = 2000]` | `Proxy` | `path` is `<node>/<app>/<object>` without wildcards (`ArgumentError`). Nothing is sent until a method is called. |
143
315
  | `proxy.<method>(*args, **kw)` | the remote return value | Waits for the answer (`timeout_ms`). `RemoteError` when it raised there or could not be called (not exposed: `NoMethodError`; wrong number of arguments: `ArgumentError`; no such object: `NameError`), `Timeout` 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`). |
144
316
  | `proxy.async.<method>(...)` | `Future` | Sends and returns at once. `EncodeError` / `Disconnected` are raised here. |
@@ -214,6 +386,22 @@ took zenoh replies through blocks and fetched the meta from inside
214
386
  `respond_to_missing?`, overflowed the stack there. Make waiting calls from
215
387
  the update loop, or use `async`.
216
388
 
389
+ The blocks the layers call (`Asterism.on_join` / `on_leave`, the ROS
390
+ `node.subscribe` and `node.every` blocks) run from `Asterism.poll` /
391
+ `node.poll`, that is on the update loop's stack, one Ruby block call deeper.
392
+ The layers walk their lists with `while` loops so that nothing else stands
393
+ between the update loop and the block. **Do not wait inside these blocks**
394
+ (no proxy call, no `node.call` / `client.call`): note what came, and make
395
+ the call from the update loop, or start a `call_async` / `async` call and
396
+ look at it later.
397
+
398
+ Measured on the P4 (Family mruby, 16 KB): an application with
399
+ `on_join` / `on_leave` blocks, relaying calls with another machine in both
400
+ directions at once, keeps 5.8 KB free (as before the blocks); a ROS node
401
+ publishing from `node.every` and receiving through a `node.subscribe` block
402
+ keeps 6.9 KB free, the same as polling `each_pending` by hand. Loading a
403
+ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
404
+
217
405
  ## Limits
218
406
 
219
407
  - Objects cannot be passed by reference (a return value is a copy), blocks
@@ -222,6 +410,14 @@ the update loop, or use `async`.
222
410
  node; when one of them closes, the others still list the node through
223
411
  their objects, but a watcher may see the node token go away.
224
412
  - No authentication: a trusted LAN is assumed.
413
+ - Pattern matching on the boards: PicoRuby's compiler (checked on Family
414
+ mruby's application VM) runs `case/in` with `deconstruct_keys`, nested
415
+ hash patterns, array patterns, guards, alternatives, ranges, pins and
416
+ `**rest`, but not two forms: a class as the value in a hash pattern
417
+ (`in {x: Float}` does not match) and, inside a block, binding a variable
418
+ of the enclosing method (`v = nil; list.each { |m| case m in {a: v} ... }`
419
+ leaves it nil). Put the `case` in a method of its own, as in the example
420
+ above.
225
421
 
226
422
  ## ROS 2 (rmw_zenoh): `Asterism::ROS` and `Asterism::CDR`
227
423
 
@@ -244,6 +440,17 @@ loop do
244
440
  pub << { data: "hello" } # or pub.publish(str.new(data: "hello"))
245
441
  sub.each_pending { |msg, info| puts "#{info && info.sequence}: #{msg.data}" }
246
442
  end
443
+
444
+ # or with blocks, run from node.poll (in the update loop):
445
+ node.every(1) { pub << { data: "tick" } }
446
+ node.subscribe("/cmd_vel", "geometry_msgs/msg/Twist") { |msg, _info| drive(msg) }
447
+ loop { node.poll }
448
+
449
+ def drive(msg)
450
+ case msg # deconstruct_keys
451
+ in { linear: { x: }, angular: { z: } } then move(x, z)
452
+ end
453
+ end
247
454
  node.close # or let the session close
248
455
  ```
249
456
 
@@ -271,7 +478,9 @@ end
271
478
  | `node.subscription(topic, type, qos: DEFAULT_QOS, depth: 16)` | `Subscription` | Subscribes and declares the subscription token. |
272
479
  | `sub.each_pending { \|msg, info\| }` | count | `info` is an `Attachment` (`sequence`, `stamp_ns`, `gid`) or nil. Samples that are not valid CDR are skipped and counted in `sub.errors`. |
273
480
  | `node.service(name, type, qos: DEFAULT_QOS, depth: 8) { \|req\| response }` | `Service` | Serves `name` (`ros2 service list`). The block gets a `type::Request` and returns a `type::Response` or a Hash of its fields. It runs from `node.poll` (or `service.handle_pending`), never behind the application. A request without rmw_zenoh's attachment, or that does not decode, is counted in `service.errors` and gets no answer. An exception from the block ends that request without an answer and is raised from `node.poll`. `service.handled`: answered so far. |
274
- | `node.poll(steps = 8)` | true / false | `session.poll(steps)`, then answers the requests waiting for this node's services. Returns what `session.poll` returns. |
481
+ | `node.poll(steps = 8)` | true / false | `session.poll(steps)`, then answers the requests waiting for this node's services, then gives what came to the `subscribe` blocks and fires the `every` timers that are due. Returns what `session.poll` returns. A waiting `client.call` polls only the session and the services (`node.pump`), not the blocks. |
482
+ | `node.subscribe(topic, type, qos: DEFAULT_QOS, depth: 16) { \|msg, info\| }` | `Subscription` | `node.subscription` whose messages (decoded, with their `Attachment` or nil) go to the block, from `node.poll`. `sub.close` ends it. Do not wait in the block (see [Stack](#stack)). |
483
+ | `node.every(seconds) { }` | `Timer` | Calls the block every `seconds` (a Float works), the first time one period from now, from `node.poll` (so no more often than the update loop polls). It keeps the period; after a long stall it starts again from now instead of firing the missed times at once. `timer.cancel`, `timer.fired` (count), `timer.period`. Do not wait in the block. |
275
484
  | `node.client(name, type, qos: DEFAULT_QOS)` | `Client` | Declares the client token. |
276
485
  | `client.call(request = nil, timeout_ms: 2000, **fields)` | `type::Response` | Sends the request (a `type::Request`, a Hash, or the fields as keywords) and waits, polling the node (its services keep answering). `Asterism::ROS::Timeout` when no response came in time, at once when nobody serves the name; `Asterism::Zenoh::Error` when the session closed. |
277
486
  | `client.call_async(request = nil, timeout_ms: 2000, **fields)` | `Call` | Sends and returns at once. `call.done?` never waits (`node.poll` moves it on); `call.value` waits and returns the response or raises like `call`; `call.response` (nil until it came), `call.took_ms`, `call.sequence`. |
@@ -0,0 +1,224 @@
1
+ # The object layer (Asterism.connect / expose / []) with blocks and a
2
+ # receiving thread (CRuby only). Asterism.connect without a block,
3
+ # Asterism.poll and the rest of the polled API work as on the boards.
4
+ #
5
+ # Asterism.connect("tcp/192.0.2.2:7447", node: "mypc", app: "demo") do |net|
6
+ # net.expose("screen", Screen.new, methods: [:say])
7
+ # net.on_join { |node| puts "joined #{node}" }
8
+ # net.on_leave { |node| puts "left #{node}" }
9
+ # net["fmruby-aaaaaa/demo/screen"].say("hello")
10
+ # net.each("*/demo/info").map(&:status)
11
+ # net.run # answer calls until Ctrl-C
12
+ # end
13
+ #
14
+ # Locking: the object layer is one connection per process with its state in
15
+ # the Asterism module, written for one thread. Every entry into it (polling,
16
+ # answering, calling, exposing, listing) holds Asterism::LOCK, so the
17
+ # receiving thread and the application's threads take turns. A call that
18
+ # waits for its answer while the receiving thread runs does not poll; it
19
+ # waits for the receiving thread's ticks (Asterism::Runner#wait_until).
20
+ require "monitor"
21
+
22
+ module Asterism
23
+ # Held by every entry into the object layer (reentrant).
24
+ LOCK = Monitor.new
25
+
26
+ # The polled API, entered under LOCK. Prepended to the Asterism module's
27
+ # singleton class, so the shared methods themselves are not changed.
28
+ module Locked
29
+ %i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave].each do |m|
30
+ define_method(m) do |*args, **kw, &blk|
31
+ ::Asterism::LOCK.synchronize { super(*args, **kw, &blk) }
32
+ end
33
+ end
34
+
35
+ # With a block: yields an Asterism::Net and closes when the block ends
36
+ # (also on an exception); returns the block's value. Without a block,
37
+ # as before (returns Asterism).
38
+ def connect(locator, node:, app:, mode: nil, listen: nil, &blk)
39
+ ::Asterism::LOCK.synchronize do
40
+ super(locator, node: node, app: app, mode: mode, listen: listen)
41
+ # The Net of a connection that was lost (not closed) belongs to the
42
+ # old session.
43
+ old = @net
44
+ @net = nil
45
+ old&.release
46
+ end
47
+ return self unless blk
48
+ net = ::Asterism.net
49
+ begin
50
+ blk.call(net)
51
+ ensure
52
+ net.close
53
+ end
54
+ end
55
+
56
+ # Stops the receiving thread first (outside the lock: it may be inside
57
+ # a tick), then closes.
58
+ def close
59
+ net = @net
60
+ @net = nil
61
+ net&.release
62
+ ::Asterism::LOCK.synchronize { super }
63
+ end
64
+
65
+ # The on_join / on_leave blocks (called from Asterism.poll): with a Net,
66
+ # what they raise goes to its on_error and the other blocks still run.
67
+ def tell(where, blk, node)
68
+ net = @net
69
+ return super unless net
70
+ net.runner.guard(where) { super }
71
+ end
72
+
73
+ # Lists under the lock, yields outside it (the block may make calls).
74
+ def each(pattern = "**", &blk)
75
+ list = ::Asterism::LOCK.synchronize { super(pattern, &nil) }
76
+ return list unless blk
77
+ list.each(&blk)
78
+ list.size
79
+ end
80
+
81
+ # A call waiting for its answer. On the receiving thread, or when it
82
+ # does not run: polls, as on the boards. On another thread while it
83
+ # runs: waits for its ticks to bring the answer.
84
+ def wait_until(&cond)
85
+ loop do
86
+ r = @net&.runner
87
+ if r && r.running? && !r.current?
88
+ r.wait_until(&cond)
89
+ return if ::Asterism::LOCK.synchronize(&cond)
90
+ # The receiving thread stopped before the answer: poll here.
91
+ else
92
+ return ::Asterism::LOCK.synchronize { super(&cond) }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ singleton_class.prepend(Locked)
98
+
99
+ class Future
100
+ # Prepended: done? reads the connection state, under the lock.
101
+ module Locked
102
+ def done?
103
+ ::Asterism::LOCK.synchronize { super }
104
+ end
105
+ end
106
+ prepend Locked
107
+ end
108
+
109
+ # The connection of this process (Asterism is one per process) as an
110
+ # object, with a receiving thread. nil when not connected.
111
+ def self.net
112
+ LOCK.synchronize do
113
+ return nil unless connected?
114
+ @net ||= Net.new
115
+ end
116
+ end
117
+
118
+ # The block API of the object layer. Made by Asterism.connect with a
119
+ # block, or Asterism.net after a connect without one.
120
+ class Net
121
+ attr_reader :runner
122
+
123
+ def initialize
124
+ @runner = Runner.new(::Asterism.instance_variable_get(:@session), lock: ::Asterism::LOCK, name: "asterism")
125
+ # One tick: Asterism.poll (answers the calls, tells on_join /
126
+ # on_leave); false once the connection is gone.
127
+ @runner.add { ::Asterism.poll ? 0 : false }
128
+ end
129
+
130
+ def node_id = ::Asterism.node_id
131
+ def app = ::Asterism.app
132
+ def connected? = ::Asterism.connected?
133
+ def lost_reason = ::Asterism.lost_reason
134
+ def nodes = ::Asterism.nodes
135
+ def exposed = ::Asterism.exposed
136
+
137
+ # Asterism.expose: methods: an Array of names or a Hash name => number
138
+ # of arguments. The object's methods run on the receiving thread (or in
139
+ # run, or in whatever polls).
140
+ def expose(name, obj, methods:)
141
+ ::Asterism.expose(name, obj, methods: methods)
142
+ end
143
+
144
+ def unexpose(name)
145
+ ::Asterism.unexpose(name)
146
+ end
147
+
148
+ # A proxy for <node>/<app>/<object>; timeout in seconds.
149
+ def [](path, timeout: nil)
150
+ ::Asterism[path, Zenoh.ms(timeout, ::Asterism::DEFAULT_TIMEOUT_MS)]
151
+ end
152
+ alias proxy []
153
+
154
+ # The exposed objects alive now matching the pattern, as proxies. With
155
+ # a block, yields each and returns their number; without, an
156
+ # Enumerator (net.each("*/demo/info").map(&:status)).
157
+ def each(pattern = "**", &blk)
158
+ return enum_for(:each, pattern) unless blk
159
+ ::Asterism.each(pattern, &blk)
160
+ end
161
+ include Enumerable
162
+
163
+ # on_join { |node| } / on_leave { |node| }: Asterism.on_join /
164
+ # on_leave, the same as on the boards (called from Asterism.poll), here
165
+ # on the receiving thread. What they raise goes to on_error.
166
+ def on_join(&blk)
167
+ ::Asterism.on_join(&blk)
168
+ self
169
+ end
170
+
171
+ def on_leave(&blk)
172
+ ::Asterism.on_leave(&blk)
173
+ self
174
+ end
175
+
176
+ # on_error { |error, where| }: what on_join / on_leave blocks raise.
177
+ # (An exposed method that raises is answered to its caller as a
178
+ # RemoteError, as before; it does not come here.)
179
+ def on_error(&blk)
180
+ @runner.on_error(&blk)
181
+ self
182
+ end
183
+
184
+ # Answers calls on a thread of its own. Returns self.
185
+ def start
186
+ @runner.start
187
+ self
188
+ end
189
+
190
+ def stop
191
+ @runner.stop
192
+ self
193
+ end
194
+
195
+ # Answers calls on this thread until stop, the connection closing, or
196
+ # Ctrl-C. Returns nil.
197
+ def run
198
+ @runner.run
199
+ end
200
+
201
+ def running?
202
+ @runner.running?
203
+ end
204
+
205
+ def poll
206
+ ::Asterism.poll
207
+ end
208
+
209
+ # Stops the receiving thread and closes the connection (Asterism.close).
210
+ def close
211
+ ::Asterism.close
212
+ nil
213
+ end
214
+
215
+ # For Asterism.close: stop receiving and forget the session.
216
+ def release
217
+ @runner.close
218
+ end
219
+
220
+ def inspect
221
+ "#<Asterism::Net #{node_id}/#{app}#{running? ? ' running' : ''}>"
222
+ end
223
+ end
224
+ end