asterism 0.1.0 → 0.2.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: bdb7bf2126361f3b9ae4097467832d950440b9a65c419a92394f230b4d7b0277
4
+ data.tar.gz: ba8621f58ee2d5fb22f5eeb3b68917cb6ce9f9420d07230d5d69e246b9c53564
5
5
  SHA512:
6
- metadata.gz: bff73ce315295e1d2a4426eef3deda857ba8c77db3c789cb48b0ec489c101cd35633fb6d73fbba31d641ad9d467bbaa7db80aefcf4686af1a798a9f4719a66c4
7
- data.tar.gz: 594b310948679e9471dab6853846692c0ffc94a670456b9fcccfe44f7c0e4fbc3cf90fde19e401f992e31897b2b385f7a1856c6a8bef5920b1b6cf75c868e7bf
6
+ metadata.gz: cc43c0ab1f0076a148bcd65a648b0220eb8559912b296a7fdbb7b4810f108ab8f66d439e951e3c1a3fe24329ac6fb5a76593214c7571e3f18b55d833a917276d
7
+ data.tar.gz: 48114f986885d604142a4fb369b1a31f4a7cb6d1dbff95ffc0b0566e97801bbd646278cb498b4c0e1003c8a3b425a526262ffb384557eeb0cdc4a7e450b6bc63
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,7 @@ 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
+ Neither gem is on rubygems.org yet (version 0.2.0 builds from these
27
27
  repositories with `rake gem`).
28
28
 
29
29
  ## Using it
@@ -31,7 +31,7 @@ repositories with `rake gem`).
31
31
  CRuby (needs CRuby 3.2+ and a C compiler):
32
32
 
33
33
  ```
34
- gem install asterism # also installs asterism-zenoh (~> 0.1.0) and msgpack
34
+ gem install asterism # also installs asterism-zenoh (~> 0.2.0) and msgpack
35
35
  ruby -e 'require "asterism"'
36
36
  ```
37
37
 
@@ -44,7 +44,7 @@ build but not yet tested. To install from the repositories instead:
44
44
 
45
45
  ```
46
46
  (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
47
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.2.0.gem pkg/asterism-0.2.0.gem
48
48
  ```
49
49
 
50
50
  From the working trees, without installing:
@@ -67,13 +67,14 @@ these repositories.
67
67
  ## Tests
68
68
 
69
69
  ```
70
- rake # both suites
70
+ rake # every suite
71
71
  rake test:msgs # generator, type hashes, CDR, bundled types (CRuby only; no Zenoh, no docker)
72
72
  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
73
+ rake test:api # the Ruby-like API (blocks, receiving threads, Enumerators)
74
+ ASTERISM_TEST_ROUTER=tcp/127.0.0.1:7447 rake test:objects test:api # the same through a zenohd router
74
75
  ```
75
76
 
76
- `test:objects` needs asterism-zenoh: built next to this repository
77
+ `test:objects` and `test:api` need asterism-zenoh: built next to this repository
77
78
  (`../asterism-zenoh`, its `rake compile`), or `ASTERISM_ZENOH_DIR`, or the
78
79
  installed gem.
79
80
 
@@ -83,11 +84,147 @@ installed gem.
83
84
  # objects: join the app "demo" of Family mruby's asterism_demo, call a board,
84
85
  # and answer its calls (its keys s / a call screen.say / apu.play here)
85
86
  ruby examples/node.rb --router tcp/192.0.2.2:7447 [--peer fmruby-aaaaaa]
87
+ ruby examples/node_polled.rb --router tcp/192.0.2.2:7447 # the same, polled
86
88
 
87
- # ROS 2: geometry_msgs/Twist on /cmd_vel, then AddTwoInts calls
89
+ # ROS 2: /chatter and /cmd_vel from a timer, /chatter_in printed,
90
+ # /cruby/add_two_ints served, AddTwoInts calls
88
91
  ruby examples/ros2_talker.rb --router tcp/192.0.2.2:7447 [--service NAME]
92
+
93
+ # plain Zenoh: run two with different --name
94
+ ruby examples/zenoh.rb --router tcp/192.0.2.2:7447 --name pc1
95
+ ```
96
+
97
+ ## The Ruby-like API (CRuby only)
98
+
99
+ On CRuby, `require "asterism"` also brings a layer with blocks, a receiving
100
+ thread, Enumerators and pattern matching (`lib/asterism/cruby/`). It is
101
+ built on the polled API below, which stays as it is: code written for a
102
+ board still runs unchanged on CRuby, and code using this layer does not run
103
+ on a board.
104
+
105
+ ### Zenoh
106
+
107
+ ```ruby
108
+ Asterism::Zenoh.open("tcp/192.0.2.2:7447") do |s| # closed when the block ends
109
+ s.subscribe("home/**") { |sample| puts "#{sample.key} = #{sample.payload}" }
110
+ s.queryable("home/pc/status") { |q| q.reply("ok") }
111
+ s.put("home/pc/hello", "hi")
112
+ s.get("home/**/status").each { |reply| p reply } # until every answer came or the time ran out
113
+ sub = s.subscribe("sensor/temp") # no block: take from it
114
+ sub.each.lazy.map { _1.payload.to_f }.first(3) # waits for the samples
115
+ s.liveliness_watch("asterism/**") { |key, alive| puts "#{key} #{alive}" }
116
+ s.run # receive on this thread until Ctrl-C
117
+ end
118
+ ```
119
+
120
+ | Call | Notes |
121
+ |---|---|
122
+ | `Asterism::Zenoh.open(locator = nil, mode:, listen:, 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 |
123
+ | `Connection.new(session)` | wraps a session opened with the polled API |
124
+ | `s.put(key, payload, attachment: nil)` | a payload that is not a String is sent as `to_s` |
125
+ | `s.subscribe(key, depth: 16) { \|sample\| }` | the block gets each `Sample` on the receiving thread. Returns a `Subscription` (`close` ends it) |
126
+ | `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` |
127
+ | `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 |
128
+ | `s.get(key, timeout: 2.0, params:, payload:, attachment:, target:, consolidation:)` | an Enumerator of `Reply`; each iteration sends the get again. With a block: yields each, returns their number |
129
+ | `s.liveliness(key)` | a token (`close`) |
130
+ | `s.liveliness_watch(key) { \|key, alive\| }` | on the receiving thread; the tokens alive now come first. Without a block, `each` yields `Liveliness` (`key`, `alive?`) |
131
+ | `s.liveliness_get(key, timeout: 2.0)` | the keys alive now (an Array) |
132
+ | `s.start` / `s.stop` / `s.running?` | receive on a thread of its own |
133
+ | `s.run` | receive on this thread until `stop`, the connection closing, or Ctrl-C (returns nil) |
134
+ | `s.on_error { \|error, where\| }` | what the blocks raise (`StandardError`); without a handler it is printed with `warn`. Receiving goes on |
135
+ | `s.session`, `zid`, `peers`, `poll`, `closed?`, `close` | |
136
+
137
+ `Sample`, `Reply` (`key`, `payload`, `attachment`; `text` is the payload as
138
+ UTF-8) and `Liveliness` are `Data` values, so they work with pattern
139
+ matching: `case sample in {key: %r{/temp\z}, payload:}`. Times are seconds
140
+ here (the polled API counts milliseconds).
141
+
142
+ ### Objects
143
+
144
+ ```ruby
145
+ Asterism.connect("tcp/192.0.2.2:7447", node: "mypc", app: "demo") do |net| # closed after
146
+ net.expose("screen", Screen.new, methods: [:say])
147
+ net.on_join { |node| puts "joined #{node}" }
148
+ net.on_leave { |node| puts "left #{node}" }
149
+ board = net["fmruby-aaaaaa/demo/screen"]
150
+ board.say("hello")
151
+ net.each("*/demo/info").map(&:status) # Enumerable
152
+ net.run # answer calls until Ctrl-C
153
+ end
89
154
  ```
90
155
 
156
+ | Call | Notes |
157
+ |---|---|
158
+ | `Asterism.connect(...) { \|net\| }` | yields an `Asterism::Net`, `Asterism.close` when the block ends; returns the block's value. Without a block, as before |
159
+ | `Asterism.net` | the `Net` of the connection made without a block (nil when not connected) |
160
+ | `net.expose` / `unexpose` / `exposed` | as `Asterism.expose`. The methods run on the receiving thread, in `run`, or in whatever polls |
161
+ | `net[path, timeout: nil]` | a proxy (`timeout` in seconds) |
162
+ | `net.each(pattern = "**")` | the proxies alive now; an Enumerator without a block |
163
+ | `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` |
164
+ | `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 |
165
+ | `net.nodes`, `node_id`, `app`, `connected?`, `lost_reason`, `poll`, `close` | |
166
+
167
+ ### ROS 2
168
+
169
+ ```ruby
170
+ Asterism::ROS.connect("tcp/192.0.2.2:7447", domain: 0) do |ros|
171
+ node = ros.node("ruby_talker")
172
+ chatter = node.publisher("/chatter", "std_msgs/msg/String")
173
+ chatter << { data: "hello from Ruby" }
174
+ node.subscribe("/odom", "nav_msgs/msg/Odometry") { |odom| puts odom.pose.pose.position.x }
175
+ node.every(0.1) { chatter << { data: Time.now.to_s } }
176
+ node.service("/add_ruby", "example_interfaces/srv/AddTwoInts") { |req| { sum: req.a + req.b } }
177
+ p node.call("/add_two_ints", "example_interfaces/srv/AddTwoInts", a: 1, b: 2).sum
178
+ node.topic("/scan", "sensor_msgs/msg/LaserScan").each.lazy.first(1)
179
+ ros.spin # until Ctrl-C
180
+ end
181
+ ```
182
+
183
+ | Call | Notes |
184
+ |---|---|
185
+ | `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` |
186
+ | `ros.node(name, namespace: "/", enclave: "/")` | an `Asterism::ROS::Node` (every method of the polled API) with the calls below |
187
+ | `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) |
188
+ | `node.topic(topic, type).each` | subscribes for the iteration and withdraws after it (`first(n)`, `break`, the time running out) |
189
+ | `node.every(seconds) { }` | the polled API's timer (run from `node.poll`), here on the receiving thread; returns a `Timer` (`cancel`, `fired`) |
190
+ | `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 |
191
+ | `node.call(name, type, request = nil, timeout: 2.0, **fields)` | waits for the response |
192
+ | `ros.spin` (`run`) / `start` / `stop` / `running?` / `on_error` / `close` | |
193
+ | `ros.zenoh` | the `Asterism::Zenoh::Connection` underneath (same receiving thread) |
194
+
195
+ `type` is a generated type or its name (`"geometry_msgs/msg/Twist"`).
196
+ Messages and `Attachment` take part in pattern matching (`deconstruct_keys`,
197
+ on the boards too): `case msg in {linear: {x:}, angular: {z:}}`. A type
198
+ that is not bundled: generate it with `tools/asterism_msggen.rb -o DIR`
199
+ (see [Message types](#message-types)) and give the directory, as
200
+ `Asterism::ROS.connect(..., type_path: "DIR")` or
201
+ `Asterism::ROS::TYPE_PATH << "DIR"`; then pass its name like any other.
202
+
203
+ ### Threads
204
+
205
+ - Each connection has at most one receiving thread, and only when the
206
+ application asks for it (`start`), or the application's own thread
207
+ inside `run` / `spin`. The blocks (subscriptions, queryables, timers,
208
+ services, exposed objects, `on_join`) run there, one after another. **A
209
+ block that takes long delays everything else that connection receives**:
210
+ hand long work to a thread or a Queue of your own.
211
+ - zenoh-c never calls Ruby: it fills the queues, and the receiving thread
212
+ empties them every 2 ms (`interval:`) with the polled API.
213
+ - A tick of the receiving thread holds the connection's lock (a `Monitor`;
214
+ for the object layer, which is one connection per process,
215
+ `Asterism::LOCK`). The application's calls into the same layer take it
216
+ too, so the layers written for one thread are never entered by two at
217
+ once.
218
+ - A call that waits for an answer (`proxy.method`, `node.call`) from another
219
+ thread while the receiving thread runs does not poll by itself: it waits
220
+ until a tick brings the answer. On the receiving thread itself (a block
221
+ that calls), or while nothing receives, it polls as on the boards.
222
+ - `stop`, `close` and the end of the block wait for the receiving thread to
223
+ end; no thread is left behind. When the connection is lost the receiving
224
+ thread ends by itself, and waiting calls raise as before.
225
+ - Enumerators (`each`, `get`, `topic`) wait on the thread that iterates
226
+ them.
227
+
91
228
  ## License
92
229
 
93
230
  MIT (LICENSE) for Asterism's code. The ROS 2 definitions in
@@ -108,7 +245,8 @@ RubyGems, and on mruby / PicoRuby the Zenoh binding is the separate mrbgem
108
245
 
109
246
  # The API
110
247
 
111
- The same on every Ruby. The examples use PicoRuby's `sleep_ms`; on CRuby
248
+ The same on every Ruby (the polled API; CRuby adds the layer described in
249
+ [The Ruby-like API](#the-ruby-like-api-cruby-only)). The examples use PicoRuby's `sleep_ms`; on CRuby
112
250
  write `sleep`. When PicoRuby's `Machine` is there it is used for the clock
113
251
  and the short pauses while waiting; otherwise `Time` and `sleep`. Besides
114
252
  `Asterism::Zenoh`, the layers need a `MessagePack` module with `pack` /
@@ -138,7 +276,8 @@ Asterism.nodes # => ["linux", "fmruby-bbbbbb"]
138
276
  | `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
277
  | `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
278
  | `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. |
279
+ | `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. |
280
+ | `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
281
  | `Asterism[path, timeout_ms = 2000]` | `Proxy` | `path` is `<node>/<app>/<object>` without wildcards (`ArgumentError`). Nothing is sent until a method is called. |
143
282
  | `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
283
  | `proxy.async.<method>(...)` | `Future` | Sends and returns at once. `EncodeError` / `Disconnected` are raised here. |
@@ -214,6 +353,22 @@ took zenoh replies through blocks and fetched the meta from inside
214
353
  `respond_to_missing?`, overflowed the stack there. Make waiting calls from
215
354
  the update loop, or use `async`.
216
355
 
356
+ The blocks the layers call (`Asterism.on_join` / `on_leave`, the ROS
357
+ `node.subscribe` and `node.every` blocks) run from `Asterism.poll` /
358
+ `node.poll`, that is on the update loop's stack, one Ruby block call deeper.
359
+ The layers walk their lists with `while` loops so that nothing else stands
360
+ between the update loop and the block. **Do not wait inside these blocks**
361
+ (no proxy call, no `node.call` / `client.call`): note what came, and make
362
+ the call from the update loop, or start a `call_async` / `async` call and
363
+ look at it later.
364
+
365
+ Measured on the P4 (Family mruby, 16 KB): an application with
366
+ `on_join` / `on_leave` blocks, relaying calls with another machine in both
367
+ directions at once, keeps 5.8 KB free (as before the blocks); a ROS node
368
+ publishing from `node.every` and receiving through a `node.subscribe` block
369
+ keeps 6.9 KB free, the same as polling `each_pending` by hand. Loading a
370
+ nested type (`geometry_msgs/msg/Twist`) at start-up took 0.6 KB more.
371
+
217
372
  ## Limits
218
373
 
219
374
  - Objects cannot be passed by reference (a return value is a copy), blocks
@@ -222,6 +377,14 @@ the update loop, or use `async`.
222
377
  node; when one of them closes, the others still list the node through
223
378
  their objects, but a watcher may see the node token go away.
224
379
  - No authentication: a trusted LAN is assumed.
380
+ - Pattern matching on the boards: PicoRuby's compiler (checked on Family
381
+ mruby's application VM) runs `case/in` with `deconstruct_keys`, nested
382
+ hash patterns, array patterns, guards, alternatives, ranges, pins and
383
+ `**rest`, but not two forms: a class as the value in a hash pattern
384
+ (`in {x: Float}` does not match) and, inside a block, binding a variable
385
+ of the enclosing method (`v = nil; list.each { |m| case m in {a: v} ... }`
386
+ leaves it nil). Put the `case` in a method of its own, as in the example
387
+ above.
225
388
 
226
389
  ## ROS 2 (rmw_zenoh): `Asterism::ROS` and `Asterism::CDR`
227
390
 
@@ -244,6 +407,17 @@ loop do
244
407
  pub << { data: "hello" } # or pub.publish(str.new(data: "hello"))
245
408
  sub.each_pending { |msg, info| puts "#{info && info.sequence}: #{msg.data}" }
246
409
  end
410
+
411
+ # or with blocks, run from node.poll (in the update loop):
412
+ node.every(1) { pub << { data: "tick" } }
413
+ node.subscribe("/cmd_vel", "geometry_msgs/msg/Twist") { |msg, _info| drive(msg) }
414
+ loop { node.poll }
415
+
416
+ def drive(msg)
417
+ case msg # deconstruct_keys
418
+ in { linear: { x: }, angular: { z: } } then move(x, z)
419
+ end
420
+ end
247
421
  node.close # or let the session close
248
422
  ```
249
423
 
@@ -271,7 +445,9 @@ end
271
445
  | `node.subscription(topic, type, qos: DEFAULT_QOS, depth: 16)` | `Subscription` | Subscribes and declares the subscription token. |
272
446
  | `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
447
  | `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. |
448
+ | `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. |
449
+ | `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)). |
450
+ | `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
451
  | `node.client(name, type, qos: DEFAULT_QOS)` | `Client` | Declares the client token. |
276
452
  | `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
453
  | `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