asterism 0.0.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/NOTICE +27 -0
  4. data/README.md +581 -3
  5. data/data/msgs/LICENSE-Apache-2.0.txt +202 -0
  6. data/data/msgs/NOTICE +17 -0
  7. data/data/msgs/builtin_interfaces/msg/Duration.rb +33 -0
  8. data/data/msgs/builtin_interfaces/msg/Time.rb +33 -0
  9. data/data/msgs/example_interfaces/srv/AddTwoInts.rb +64 -0
  10. data/data/msgs/geometry_msgs/msg/Accel.rb +35 -0
  11. data/data/msgs/geometry_msgs/msg/AccelStamped.rb +36 -0
  12. data/data/msgs/geometry_msgs/msg/Point.rb +36 -0
  13. data/data/msgs/geometry_msgs/msg/Point32.rb +36 -0
  14. data/data/msgs/geometry_msgs/msg/PointStamped.rb +36 -0
  15. data/data/msgs/geometry_msgs/msg/Pose.rb +36 -0
  16. data/data/msgs/geometry_msgs/msg/Pose2D.rb +36 -0
  17. data/data/msgs/geometry_msgs/msg/PoseStamped.rb +36 -0
  18. data/data/msgs/geometry_msgs/msg/Quaternion.rb +39 -0
  19. data/data/msgs/geometry_msgs/msg/QuaternionStamped.rb +36 -0
  20. data/data/msgs/geometry_msgs/msg/Transform.rb +36 -0
  21. data/data/msgs/geometry_msgs/msg/TransformStamped.rb +39 -0
  22. data/data/msgs/geometry_msgs/msg/Twist.rb +35 -0
  23. data/data/msgs/geometry_msgs/msg/TwistStamped.rb +36 -0
  24. data/data/msgs/geometry_msgs/msg/Vector3.rb +36 -0
  25. data/data/msgs/geometry_msgs/msg/Vector3Stamped.rb +36 -0
  26. data/data/msgs/geometry_msgs/msg/Wrench.rb +35 -0
  27. data/data/msgs/geometry_msgs/msg/WrenchStamped.rb +36 -0
  28. data/data/msgs/sensor_msgs/msg/BatteryState.rb +100 -0
  29. data/data/msgs/sensor_msgs/msg/FluidPressure.rb +38 -0
  30. data/data/msgs/sensor_msgs/msg/Illuminance.rb +38 -0
  31. data/data/msgs/sensor_msgs/msg/Imu.rb +52 -0
  32. data/data/msgs/sensor_msgs/msg/JointState.rb +44 -0
  33. data/data/msgs/sensor_msgs/msg/MagneticField.rb +39 -0
  34. data/data/msgs/sensor_msgs/msg/NavSatFix.rb +55 -0
  35. data/data/msgs/sensor_msgs/msg/NavSatStatus.rb +43 -0
  36. data/data/msgs/sensor_msgs/msg/Range.rb +52 -0
  37. data/data/msgs/sensor_msgs/msg/RelativeHumidity.rb +38 -0
  38. data/data/msgs/sensor_msgs/msg/Temperature.rb +38 -0
  39. data/data/msgs/std_msgs/msg/Bool.rb +30 -0
  40. data/data/msgs/std_msgs/msg/Byte.rb +30 -0
  41. data/data/msgs/std_msgs/msg/ByteMultiArray.rb +35 -0
  42. data/data/msgs/std_msgs/msg/Char.rb +30 -0
  43. data/data/msgs/std_msgs/msg/ColorRGBA.rb +39 -0
  44. data/data/msgs/std_msgs/msg/Empty.rb +26 -0
  45. data/data/msgs/std_msgs/msg/Float32.rb +30 -0
  46. data/data/msgs/std_msgs/msg/Float32MultiArray.rb +35 -0
  47. data/data/msgs/std_msgs/msg/Float64.rb +30 -0
  48. data/data/msgs/std_msgs/msg/Float64MultiArray.rb +35 -0
  49. data/data/msgs/std_msgs/msg/Header.rb +35 -0
  50. data/data/msgs/std_msgs/msg/Int16.rb +30 -0
  51. data/data/msgs/std_msgs/msg/Int16MultiArray.rb +35 -0
  52. data/data/msgs/std_msgs/msg/Int32.rb +30 -0
  53. data/data/msgs/std_msgs/msg/Int32MultiArray.rb +35 -0
  54. data/data/msgs/std_msgs/msg/Int64.rb +30 -0
  55. data/data/msgs/std_msgs/msg/Int64MultiArray.rb +35 -0
  56. data/data/msgs/std_msgs/msg/Int8.rb +30 -0
  57. data/data/msgs/std_msgs/msg/Int8MultiArray.rb +35 -0
  58. data/data/msgs/std_msgs/msg/MultiArrayDimension.rb +36 -0
  59. data/data/msgs/std_msgs/msg/MultiArrayLayout.rb +35 -0
  60. data/data/msgs/std_msgs/msg/String.rb +30 -0
  61. data/data/msgs/std_msgs/msg/UInt16.rb +30 -0
  62. data/data/msgs/std_msgs/msg/UInt16MultiArray.rb +35 -0
  63. data/data/msgs/std_msgs/msg/UInt32.rb +30 -0
  64. data/data/msgs/std_msgs/msg/UInt32MultiArray.rb +35 -0
  65. data/data/msgs/std_msgs/msg/UInt64.rb +30 -0
  66. data/data/msgs/std_msgs/msg/UInt64MultiArray.rb +35 -0
  67. data/data/msgs/std_msgs/msg/UInt8.rb +30 -0
  68. data/data/msgs/std_msgs/msg/UInt8MultiArray.rb +35 -0
  69. data/lib/asterism/cruby/objects.rb +224 -0
  70. data/lib/asterism/cruby/ros.rb +305 -0
  71. data/lib/asterism/cruby/runner.rb +253 -0
  72. data/lib/asterism/cruby/zenoh.rb +422 -0
  73. data/lib/asterism/cruby.rb +30 -0
  74. data/lib/asterism/version.rb +5 -0
  75. data/lib/asterism.rb +22 -4
  76. data/mrblib/asterism.rb +617 -0
  77. data/mrblib/cdr.rb +512 -0
  78. data/mrblib/future.rb +88 -0
  79. data/mrblib/proxy.rb +97 -0
  80. data/mrblib/ros.rb +861 -0
  81. data/tools/asterism_msggen.rb +826 -0
  82. metadata +122 -9
data/lib/asterism.rb CHANGED
@@ -1,5 +1,23 @@
1
- # Asterism: treat many Rubies (CRuby, mruby, microcontrollers, the web)
2
- # as one network of objects, on top of Zenoh. Not implemented yet.
3
- module Asterism
4
- VERSION = "0.0.0"
1
+ # Asterism for CRuby: Ruby objects on other machines called like local ones
2
+ # (Asterism.connect / expose / [] / poll), and a ROS 2 node over rmw_zenoh's
3
+ # wire format (Asterism::ROS, Asterism::CDR, generated message types).
4
+ #
5
+ # The Ruby code is the one the mruby / PicoRuby boards run: mrblib/ of this
6
+ # repository, which is also the mrbgem (mrbgem.rake). Nothing is copied:
7
+ # this file loads mrblib/ as it is, and lib/asterism/cruby.rb adds what
8
+ # CRuby needs. The Zenoh binding is the asterism-zenoh gem (Asterism::Zenoh,
9
+ # over zenoh-c), with the same API as the boards' zenoh-pico binding.
10
+ require "msgpack"
11
+ require "asterism/zenoh"
12
+ require_relative "asterism/version"
13
+
14
+ # The same order as an mrbgem's mrblib (alphabetical).
15
+ %w[asterism cdr future proxy ros].each do |f|
16
+ require_relative "../mrblib/#{f}"
17
+ end
18
+ require_relative "asterism/cruby"
19
+ # The Ruby-like API on top (blocks, a receiving thread, Enumerators,
20
+ # pattern matching): CRuby only, see README "The Ruby-like API".
21
+ %w[runner zenoh objects ros].each do |f|
22
+ require_relative "asterism/cruby/#{f}"
5
23
  end
@@ -0,0 +1,617 @@
1
+ # Asterism: Ruby objects on other machines, called like local ones.
2
+ #
3
+ # Asterism.connect("tcp/192.0.2.2:7447", node: "fmruby-bbbbbb", app: "demo")
4
+ # Asterism.expose("apu", apu, methods: [:play, :stop])
5
+ # p = Asterism["linux/demo/apu"] # <node>/<app>/<object>
6
+ # p.play("cde") # waits for the answer (2 s by default)
7
+ # Asterism.poll # call regularly (answers incoming calls)
8
+ #
9
+ # Keys (doc/ruby_asterism/design.md ch. 4):
10
+ # asterism/<node>/<app>/<object>/call get: payload [method, args, kwargs]
11
+ # reply ["ok", value] or
12
+ # ["error", class name, message]
13
+ # asterism/<node>/<app>/<object>/meta get: reply {"methods" => [[name, arity], ...]}
14
+ # asterism/<node> liveliness: the node is up
15
+ # asterism/<node>/<app>/<object> liveliness: the object is exposed
16
+ #
17
+ # Everything is polled: nothing runs behind the application's back. A call
18
+ # that waits for its answer keeps polling meanwhile and answers the calls
19
+ # that come in, so two machines calling each other do not lock up.
20
+ module Asterism
21
+ ROOT = "asterism"
22
+ DEFAULT_TIMEOUT_MS = 2000
23
+ # Waits that may be open inside each other (a call answered while waiting
24
+ # that itself calls and waits, and so on).
25
+ MAX_NESTING = 4
26
+ # Pause between polls while a call waits (ms).
27
+ WAIT_STEP_MS = 2
28
+
29
+ class Error < StandardError; end
30
+
31
+ # A value that MessagePack cannot carry (only nil, true, false, Integer,
32
+ # Float, String, Symbol (sent as a String), Array and Hash can be sent).
33
+ class EncodeError < Error; end
34
+
35
+ # No answer within the time limit (the object is gone, or slow).
36
+ class Timeout < Error; end
37
+
38
+ # The connection is closed or was lost (Asterism::Zenoh::Error inside).
39
+ class Disconnected < Error; end
40
+
41
+ # The remote method raised, or could not be called (not exposed, wrong
42
+ # number of arguments). remote_class is the class name on the other side.
43
+ class RemoteError < Error
44
+ attr_reader :remote_class, :remote_message
45
+
46
+ def initialize(remote_class, remote_message)
47
+ @remote_class = remote_class.to_s
48
+ @remote_message = remote_message.to_s
49
+ super("#{@remote_class}: #{@remote_message}")
50
+ end
51
+ end
52
+
53
+ # ------------------------------------------------------------ values
54
+
55
+ module Codec
56
+ MAX_DEPTH = 16
57
+
58
+ # The value as it will be sent (Symbols become Strings), or EncodeError.
59
+ def self.check(v, depth = 0)
60
+ raise ::Asterism::EncodeError, "value nested too deep (max #{MAX_DEPTH})" if depth > MAX_DEPTH
61
+ if v.nil? || v == true || v == false || v.is_a?(Integer) || v.is_a?(Float) || v.is_a?(String)
62
+ v
63
+ elsif v.is_a?(Symbol)
64
+ v.to_s
65
+ elsif v.is_a?(Array)
66
+ v.map { |e| check(e, depth + 1) }
67
+ elsif v.is_a?(Hash)
68
+ h = {}
69
+ v.each { |k, x| h[check(k, depth + 1)] = check(x, depth + 1) }
70
+ h
71
+ else
72
+ raise ::Asterism::EncodeError, "cannot send a #{v.class} (only nil, true, false, Integer, " \
73
+ "Float, String, Symbol, Array, Hash)"
74
+ end
75
+ end
76
+
77
+ def self.pack(v)
78
+ ::MessagePack.pack(check(v))
79
+ end
80
+
81
+ # nil when the bytes are not MessagePack.
82
+ def self.unpack(bytes)
83
+ return nil if bytes.nil? || bytes.empty?
84
+ ::MessagePack.unpack(bytes)
85
+ rescue
86
+ nil
87
+ end
88
+ end
89
+
90
+ # ------------------------------------------------------------ clock
91
+
92
+ # Milliseconds and a short pause. PicoRuby's Machine when it is there,
93
+ # otherwise Time and sleep.
94
+ def self.now_ms
95
+ if machine?
96
+ ::Machine.board_millis
97
+ else
98
+ (Time.now.to_f * 1000).to_i
99
+ end
100
+ end
101
+
102
+ def self.pause(ms)
103
+ if machine?
104
+ ::Machine.delay_ms(ms)
105
+ else
106
+ sleep(ms / 1000.0)
107
+ end
108
+ end
109
+
110
+ def self.machine?
111
+ @machine = (Object.const_defined?(:Machine) && ::Machine.respond_to?(:delay_ms)) if @machine.nil?
112
+ @machine
113
+ end
114
+
115
+ # ------------------------------------------------------------ names
116
+
117
+ # A node, app or object name: one key chunk, no wildcard or special
118
+ # characters.
119
+ def self.check_name(what, name)
120
+ s = name.to_s
121
+ bad = s.empty? || s.start_with?("@")
122
+ ["/", "*", "$", "?", "#"].each { |c| bad = true if s.include?(c) }
123
+ raise ArgumentError, "invalid #{what} name: #{name.inspect}" if bad
124
+ s
125
+ end
126
+
127
+ # "node/app/object" -> [node, app, object]
128
+ def self.split_path(path)
129
+ parts = path.to_s.split("/")
130
+ raise ArgumentError, "expected <node>/<app>/<object>, got #{path.inspect}" unless parts.size == 3
131
+ check_name("node", parts[0])
132
+ check_name("app", parts[1])
133
+ check_name("object", parts[2])
134
+ parts
135
+ end
136
+
137
+ # Glob over "/"-separated chunks: * is one chunk, ** any number of them.
138
+ def self.glob?(pattern, path)
139
+ glob_parts?(pattern.split("/"), 0, path.split("/"), 0)
140
+ end
141
+
142
+ def self.glob_parts?(pat, i, parts, j)
143
+ while i < pat.size
144
+ if pat[i] == "**"
145
+ k = j
146
+ while k <= parts.size
147
+ return true if glob_parts?(pat, i + 1, parts, k)
148
+ k += 1
149
+ end
150
+ return false
151
+ end
152
+ return false if j >= parts.size
153
+ return false unless pat[i] == "*" || pat[i] == parts[j]
154
+ i += 1
155
+ j += 1
156
+ end
157
+ j == parts.size
158
+ end
159
+
160
+ # ------------------------------------------------------------ connection
161
+
162
+ # Opens the connection. locator, mode: and listen: go to
163
+ # Asterism::Zenoh::Session.open (client of a router by default; mode: :peer
164
+ # with or without listen: for no router). node: is this machine's ID and
165
+ # app: this application's name. Raises Disconnected when it cannot connect,
166
+ # and Error when the same <node>/<app> is already on the network.
167
+ def self.connect(locator, node:, app:, mode: nil, listen: nil)
168
+ raise Error, "already connected (Asterism.close first)" if connected?
169
+ release
170
+ node = check_name("node", node)
171
+ app = check_name("app", app)
172
+ begin
173
+ s = Asterism::Zenoh::Session.open(locator, mode: mode, listen: listen)
174
+ rescue Asterism::Zenoh::Error => e
175
+ raise Disconnected, e.message
176
+ end
177
+ begin
178
+ if already_there?(s, node, app)
179
+ s.close
180
+ raise Error, "#{node}/#{app} is already on the network"
181
+ end
182
+ @node_token = s.liveliness("#{ROOT}/#{node}")
183
+ @queryable = s.queryable("#{ROOT}/#{node}/#{app}/**", 32)
184
+ @watch = s.liveliness_watch("#{ROOT}/**", 64)
185
+ rescue Asterism::Zenoh::Error => e
186
+ s.close
187
+ raise Disconnected, e.message
188
+ end
189
+ @session = s
190
+ @node = node
191
+ @app = app
192
+ @objects = {} # name => [object, {method name => arity}, token]
193
+ @alive = {} # "node/app/object" => true (from liveliness)
194
+ @nodes = {} # node => true (from liveliness)
195
+ @proxies = {}
196
+ @depth = 0
197
+ @lost = nil
198
+ @known = [] # the other nodes told to on_join so far
199
+ self
200
+ end
201
+
202
+ # Whether objects of node/app are alive already (a second copy of the
203
+ # same application). Waits for the answer (a router answers at once).
204
+ def self.already_there?(s, node, app)
205
+ g = s.liveliness_get("#{ROOT}/#{node}/#{app}/**", 1000)
206
+ t0 = now_ms
207
+ found = false
208
+ while true
209
+ s.poll
210
+ ended = g.done? # before taking the replies (see Future#collect)
211
+ found = true if g.each_reply.size > 0
212
+ break if found || ended || now_ms - t0 > 1500
213
+ pause(WAIT_STEP_MS)
214
+ end
215
+ found
216
+ end
217
+
218
+ def self.connected?
219
+ return false if @session.nil?
220
+ if @session.closed?
221
+ lost("the connection was lost")
222
+ return false
223
+ end
224
+ true
225
+ end
226
+
227
+ def self.node_id
228
+ @node
229
+ end
230
+
231
+ def self.app
232
+ @app
233
+ end
234
+
235
+ # Why the connection went away (nil while connected or after close).
236
+ def self.lost_reason
237
+ @lost
238
+ end
239
+
240
+ # Closes the connection (and with it every exposed object), and forgets
241
+ # the on_join / on_leave blocks. Idempotent.
242
+ def self.close
243
+ release
244
+ @known = nil
245
+ @on_join = nil
246
+ @on_leave = nil
247
+ nil
248
+ end
249
+
250
+ def self.release
251
+ s = @session
252
+ @session = nil
253
+ @objects = {}
254
+ @alive = {}
255
+ @nodes = {}
256
+ @proxies = {}
257
+ s.close if s
258
+ @node_token = nil
259
+ @queryable = nil
260
+ @watch = nil
261
+ end
262
+
263
+ def self.session!
264
+ raise Disconnected, (@lost || "not connected") unless @session
265
+ @session
266
+ end
267
+
268
+ # The connection went away: closed, but the next Asterism.poll still tells
269
+ # on_leave about every node known until then.
270
+ def self.lost(reason)
271
+ @lost = reason
272
+ release
273
+ end
274
+
275
+ # ------------------------------------------------------------ exposing
276
+
277
+ # Makes obj callable from other machines as <node>/<app>/<name>. Only the
278
+ # listed methods can be called. methods: is an Array of names, or a Hash
279
+ # name => number of arguments (checked before calling; -1 for any).
280
+ def self.expose(name, obj, methods:)
281
+ s = session!
282
+ name = check_name("object", name)
283
+ table = {}
284
+ if methods.is_a?(Hash)
285
+ methods.each { |m, n| table[m.to_s] = n.to_i }
286
+ else
287
+ methods.each { |m| table[m.to_s] = -1 }
288
+ end
289
+ raise ArgumentError, "no method to expose" if table.empty?
290
+ table.each_key do |m|
291
+ raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
292
+ end
293
+ unexpose(name)
294
+ token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
295
+ @objects[name] = [obj, table, token]
296
+ "#{@node}/#{@app}/#{name}"
297
+ rescue Asterism::Zenoh::Error => e
298
+ raise Disconnected, e.message
299
+ end
300
+
301
+ def self.unexpose(name)
302
+ entry = @objects ? @objects.delete(name.to_s) : nil
303
+ entry[2].close if entry
304
+ !entry.nil?
305
+ end
306
+
307
+ def self.exposed
308
+ @objects ? @objects.keys : []
309
+ end
310
+
311
+ # ------------------------------------------------------------ polling
312
+
313
+ # Call from the application's update loop. Answers the calls that came
314
+ # in, follows who is alive and calls the on_join / on_leave blocks. false
315
+ # once the connection is closed.
316
+ def self.poll
317
+ ok = @session ? pump : false
318
+ tell_nodes
319
+ ok
320
+ end
321
+
322
+ # ------------------------------------------------------------ nodes coming and going
323
+
324
+ # on_join { |node| }: another node appeared (its node token or one of its
325
+ # objects). The nodes there already join on the first polls after
326
+ # connecting. on_leave { |node| }: a node is gone (no token and no object
327
+ # left); when the connection is lost, every node known until then leaves.
328
+ #
329
+ # The blocks run from Asterism.poll only, never from the polling inside a
330
+ # waiting call. What a block raises comes out of Asterism.poll (on CRuby
331
+ # with a receiving thread: to on_error). Asterism.close forgets them.
332
+ def self.on_join(&blk)
333
+ raise ArgumentError, "on_join needs a block" unless blk
334
+ (@on_join ||= []) << blk
335
+ self
336
+ end
337
+
338
+ def self.on_leave(&blk)
339
+ raise ArgumentError, "on_leave needs a block" unless blk
340
+ (@on_leave ||= []) << blk
341
+ self
342
+ end
343
+
344
+ # Compares the nodes alive now with those told before and calls the
345
+ # blocks for the difference.
346
+ def self.tell_nodes
347
+ known = @known
348
+ return if known.nil?
349
+ now = @session ? nodes : []
350
+ now.shift if @session # this node
351
+ joined = []
352
+ left = []
353
+ now.each { |n| joined << n unless known.include?(n) }
354
+ known.each { |n| left << n unless now.include?(n) }
355
+ # After a loss, everything has been told: nothing more to compare.
356
+ @known = @session ? now : nil
357
+ tell_each("on_join", @on_join, joined)
358
+ tell_each("on_leave", @on_leave, left)
359
+ end
360
+
361
+ # While loops, not each with a block: the blocks are the application's,
362
+ # and nothing else stands between them and the update loop on the stack.
363
+ def self.tell_each(where, blocks, names)
364
+ return if blocks.nil? || names.empty?
365
+ i = 0
366
+ while i < names.size
367
+ j = 0
368
+ while j < blocks.size
369
+ tell(where, blocks[j], names[i])
370
+ j += 1
371
+ end
372
+ i += 1
373
+ end
374
+ end
375
+
376
+ # One block for one node (CRuby routes what it raises to on_error).
377
+ def self.tell(_where, blk, node)
378
+ blk.call(node)
379
+ end
380
+
381
+ def self.pump
382
+ s = @session
383
+ return false unless s
384
+ unless s.poll
385
+ lost("the connection was lost")
386
+ return false
387
+ end
388
+ answer_calls
389
+ follow_liveliness
390
+ !@session.nil?
391
+ rescue Asterism::Zenoh::Error => e
392
+ lost(e.message)
393
+ false
394
+ end
395
+
396
+ # Polls until the block is true. Answers incoming calls meanwhile.
397
+ def self.wait_until
398
+ raise Error, "calls nested too deep (max #{MAX_NESTING})" if (@depth || 0) >= MAX_NESTING
399
+ @depth = (@depth || 0) + 1
400
+ begin
401
+ until yield
402
+ break unless pump
403
+ break if yield
404
+ pause(WAIT_STEP_MS)
405
+ end
406
+ ensure
407
+ @depth -= 1
408
+ end
409
+ end
410
+
411
+ def self.depth
412
+ (@depth || 0)
413
+ end
414
+
415
+ def self.follow_liveliness
416
+ w = @watch
417
+ return unless w
418
+ # Array forms instead of blocks called from C (see Future#collect).
419
+ events = w.each_pending
420
+ i = 0
421
+ while i < events.size
422
+ key = events[i][0]
423
+ alive = events[i][1]
424
+ i += 1
425
+ rest = key[ROOT.length + 1, key.length].to_s
426
+ parts = rest.split("/")
427
+ if parts.size == 1
428
+ if alive
429
+ @nodes[rest] = true
430
+ else
431
+ @nodes.delete(rest)
432
+ end
433
+ elsif parts.size == 3
434
+ if alive
435
+ @alive[rest] = true
436
+ else
437
+ @alive.delete(rest)
438
+ end
439
+ end
440
+ end
441
+ end
442
+
443
+ # The node IDs alive now, this one first.
444
+ def self.nodes
445
+ return [] unless @session
446
+ list = [@node]
447
+ @nodes.each_key { |n| list << n unless list.include?(n) }
448
+ @alive.each_key do |path|
449
+ n = path.split("/")[0]
450
+ list << n unless list.include?(n)
451
+ end
452
+ list
453
+ end
454
+
455
+ # The exposed objects alive now whose <node>/<app>/<object> matches the
456
+ # pattern (* is one chunk, ** any number), this application's own
457
+ # included. With a block, yields a proxy for each; else returns them.
458
+ def self.each(pattern = "**")
459
+ paths = []
460
+ if @session
461
+ @objects.each_key do |name|
462
+ path = "#{@node}/#{@app}/#{name}"
463
+ paths << path if glob?(pattern, path)
464
+ end
465
+ @alive.each_key { |path| paths << path if glob?(pattern, path) && !paths.include?(path) }
466
+ end
467
+ proxies = paths.map { |path| self[path] }
468
+ return proxies unless block_given?
469
+ proxies.each { |px| yield px }
470
+ proxies.size
471
+ end
472
+
473
+ # A proxy for <node>/<app>/<object>. Nothing is sent until a method is
474
+ # called on it.
475
+ def self.[](path, timeout_ms = DEFAULT_TIMEOUT_MS)
476
+ split_path(path)
477
+ @proxies ||= {}
478
+ key = "#{path}|#{timeout_ms}"
479
+ @proxies[key] ||= Proxy.new(path.to_s, timeout_ms)
480
+ end
481
+
482
+ # ------------------------------------------------------------ calling
483
+
484
+ # Starts a call; returns a Future. Raises EncodeError (before sending)
485
+ # and Disconnected.
486
+ def self.call_async(path, method, args, kwargs, timeout_ms)
487
+ parts = split_path(path)
488
+ payload = Codec.pack([method.to_s, args, kwargs])
489
+ if parts[0] == @node && parts[1] == @app && @session
490
+ # This application's own object: no network (a session does not see
491
+ # its own queryable), same encoding and checks as a remote call.
492
+ return Future.answered(path, method, dispatch(parts[2], payload))
493
+ end
494
+ begin
495
+ g = session!.get("#{ROOT}/#{path}/call", timeout_ms, nil, payload)
496
+ rescue Asterism::Zenoh::Error => e
497
+ lost(e.message)
498
+ raise Disconnected, e.message
499
+ end
500
+ Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
501
+ end
502
+
503
+ def self.call(path, method, args, kwargs, timeout_ms)
504
+ call_async(path, method, args, kwargs, timeout_ms).value
505
+ end
506
+
507
+ # The meta reply of path: {"methods" => [[name, arity], ...]}.
508
+ def self.meta(path, timeout_ms = DEFAULT_TIMEOUT_MS)
509
+ parts = split_path(path)
510
+ if parts[0] == @node && parts[1] == @app && @session
511
+ m = meta_of(parts[2])
512
+ raise RemoteError.new("NameError", "no object #{parts[2]} exposed by #{@node}/#{@app}") unless m
513
+ return m
514
+ end
515
+ begin
516
+ g = session!.get("#{ROOT}/#{path}/meta", timeout_ms)
517
+ rescue Asterism::Zenoh::Error => e
518
+ lost(e.message)
519
+ raise Disconnected, e.message
520
+ end
521
+ f = Future.new(path, "meta", g, now_ms + timeout_ms, timeout_ms)
522
+ f.raw_value
523
+ end
524
+
525
+ # ------------------------------------------------------------ answering
526
+
527
+ def self.answer_calls
528
+ qa = @queryable
529
+ return unless qa
530
+ # Without a block, so that a handler that waits (and so polls) does not
531
+ # re-enter the queryable's iteration.
532
+ queries = qa.each_pending
533
+ i = 0
534
+ while i < queries.size
535
+ q = queries[i]
536
+ i += 1
537
+ begin
538
+ answer(q)
539
+ ensure
540
+ begin
541
+ q.finish
542
+ rescue Asterism::Zenoh::Error
543
+ # the session went away meanwhile
544
+ end
545
+ end
546
+ end
547
+ end
548
+
549
+ # asterism/<node>/<app>/<object>/<call|meta>; the node and app may come
550
+ # as wildcards (the query matched this application's queryable).
551
+ def self.answer(q)
552
+ parts = q.key.split("/")
553
+ return unless parts.size == 5
554
+ obj = parts[3]
555
+ kind = parts[4]
556
+ prefix = "#{ROOT}/#{@node}/#{@app}/"
557
+ if kind == "meta"
558
+ names = obj == "*" ? @objects.keys : [obj]
559
+ names.each do |n|
560
+ m = meta_of(n)
561
+ q.reply("#{prefix}#{n}/meta", Codec.pack(m)) if m
562
+ end
563
+ elsif kind == "call" && obj != "*"
564
+ q.reply("#{prefix}#{obj}/call", dispatch(obj, q.payload))
565
+ end
566
+ end
567
+
568
+ def self.meta_of(name)
569
+ entry = @objects[name]
570
+ return nil unless entry
571
+ list = []
572
+ entry[1].each { |m, n| list << [m, n] }
573
+ { "methods" => list }
574
+ end
575
+
576
+ # Runs a call on an exposed object; returns the packed reply.
577
+ def self.dispatch(obj_name, payload)
578
+ req = Codec.unpack(payload)
579
+ unless req.is_a?(Array) && req.size == 3 && req[0].is_a?(String) && req[1].is_a?(Array) && req[2].is_a?(Hash)
580
+ return error_reply("ArgumentError", "malformed call (expected [method, args, kwargs])")
581
+ end
582
+ entry = @objects[obj_name]
583
+ return error_reply("NameError", "no object #{obj_name} exposed by #{@node}/#{@app}") unless entry
584
+ name = req[0]
585
+ args = req[1]
586
+ kw = req[2]
587
+ arity = entry[1][name]
588
+ if arity.nil?
589
+ return error_reply("NoMethodError", "undefined method '#{name}' for #{@node}/#{@app}/#{obj_name} (not exposed)")
590
+ end
591
+ if arity >= 0 && args.size != arity
592
+ return error_reply("ArgumentError", "wrong number of arguments (given #{args.size}, expected #{arity})")
593
+ end
594
+ begin
595
+ value = invoke(entry[0], name, args, kw)
596
+ Codec.pack(["ok", value])
597
+ rescue EncodeError => e
598
+ error_reply("Asterism::EncodeError", "the return value: #{e.message}")
599
+ rescue => e
600
+ error_reply(e.class.to_s, e.message)
601
+ end
602
+ end
603
+
604
+ def self.invoke(obj, name, args, kw)
605
+ if kw.empty?
606
+ obj.public_send(name, *args)
607
+ else
608
+ opts = {}
609
+ kw.each { |k, v| opts[k.to_s.to_sym] = v }
610
+ obj.public_send(name, *args, **opts)
611
+ end
612
+ end
613
+
614
+ def self.error_reply(klass, message)
615
+ ::MessagePack.pack(["error", klass.to_s, message.to_s])
616
+ end
617
+ end