asterism 0.0.0 → 0.1.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/NOTICE +27 -0
  4. data/README.md +405 -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.rb +24 -0
  70. data/lib/asterism/version.rb +5 -0
  71. data/lib/asterism.rb +17 -4
  72. data/mrblib/asterism.rb +545 -0
  73. data/mrblib/cdr.rb +512 -0
  74. data/mrblib/future.rb +83 -0
  75. data/mrblib/proxy.rb +97 -0
  76. data/mrblib/ros.rb +691 -0
  77. data/tools/asterism_msggen.rb +826 -0
  78. metadata +118 -9
@@ -0,0 +1,545 @@
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
+ close
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
+ self
199
+ end
200
+
201
+ # Whether objects of node/app are alive already (a second copy of the
202
+ # same application). Waits for the answer (a router answers at once).
203
+ def self.already_there?(s, node, app)
204
+ g = s.liveliness_get("#{ROOT}/#{node}/#{app}/**", 1000)
205
+ t0 = now_ms
206
+ found = false
207
+ while true
208
+ s.poll
209
+ found = true if g.each_reply.size > 0
210
+ break if found || g.done? || now_ms - t0 > 1500
211
+ pause(WAIT_STEP_MS)
212
+ end
213
+ found
214
+ end
215
+
216
+ def self.connected?
217
+ return false if @session.nil?
218
+ if @session.closed?
219
+ lost("the connection was lost")
220
+ return false
221
+ end
222
+ true
223
+ end
224
+
225
+ def self.node_id
226
+ @node
227
+ end
228
+
229
+ def self.app
230
+ @app
231
+ end
232
+
233
+ # Why the connection went away (nil while connected or after close).
234
+ def self.lost_reason
235
+ @lost
236
+ end
237
+
238
+ # Closes the connection (and with it every exposed object). Idempotent.
239
+ def self.close
240
+ s = @session
241
+ @session = nil
242
+ @objects = {}
243
+ @alive = {}
244
+ @nodes = {}
245
+ @proxies = {}
246
+ s.close if s
247
+ @node_token = nil
248
+ @queryable = nil
249
+ @watch = nil
250
+ nil
251
+ end
252
+
253
+ def self.session!
254
+ raise Disconnected, (@lost || "not connected") unless @session
255
+ @session
256
+ end
257
+
258
+ def self.lost(reason)
259
+ @lost = reason
260
+ close
261
+ @lost = reason
262
+ end
263
+
264
+ # ------------------------------------------------------------ exposing
265
+
266
+ # Makes obj callable from other machines as <node>/<app>/<name>. Only the
267
+ # listed methods can be called. methods: is an Array of names, or a Hash
268
+ # name => number of arguments (checked before calling; -1 for any).
269
+ def self.expose(name, obj, methods:)
270
+ s = session!
271
+ name = check_name("object", name)
272
+ table = {}
273
+ if methods.is_a?(Hash)
274
+ methods.each { |m, n| table[m.to_s] = n.to_i }
275
+ else
276
+ methods.each { |m| table[m.to_s] = -1 }
277
+ end
278
+ raise ArgumentError, "no method to expose" if table.empty?
279
+ table.each_key do |m|
280
+ raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
281
+ end
282
+ unexpose(name)
283
+ token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
284
+ @objects[name] = [obj, table, token]
285
+ "#{@node}/#{@app}/#{name}"
286
+ rescue Asterism::Zenoh::Error => e
287
+ raise Disconnected, e.message
288
+ end
289
+
290
+ def self.unexpose(name)
291
+ entry = @objects ? @objects.delete(name.to_s) : nil
292
+ entry[2].close if entry
293
+ !entry.nil?
294
+ end
295
+
296
+ def self.exposed
297
+ @objects ? @objects.keys : []
298
+ end
299
+
300
+ # ------------------------------------------------------------ polling
301
+
302
+ # Call from the application's update loop. Answers the calls that came
303
+ # in and follows who is alive. false once the connection is closed.
304
+ def self.poll
305
+ return false unless @session
306
+ pump
307
+ end
308
+
309
+ def self.pump
310
+ s = @session
311
+ return false unless s
312
+ unless s.poll
313
+ lost("the connection was lost")
314
+ return false
315
+ end
316
+ answer_calls
317
+ follow_liveliness
318
+ !@session.nil?
319
+ rescue Asterism::Zenoh::Error => e
320
+ lost(e.message)
321
+ false
322
+ end
323
+
324
+ # Polls until the block is true. Answers incoming calls meanwhile.
325
+ def self.wait_until
326
+ raise Error, "calls nested too deep (max #{MAX_NESTING})" if (@depth || 0) >= MAX_NESTING
327
+ @depth = (@depth || 0) + 1
328
+ begin
329
+ until yield
330
+ break unless pump
331
+ break if yield
332
+ pause(WAIT_STEP_MS)
333
+ end
334
+ ensure
335
+ @depth -= 1
336
+ end
337
+ end
338
+
339
+ def self.depth
340
+ (@depth || 0)
341
+ end
342
+
343
+ def self.follow_liveliness
344
+ w = @watch
345
+ return unless w
346
+ # Array forms instead of blocks called from C (see Future#collect).
347
+ events = w.each_pending
348
+ i = 0
349
+ while i < events.size
350
+ key = events[i][0]
351
+ alive = events[i][1]
352
+ i += 1
353
+ rest = key[ROOT.length + 1, key.length].to_s
354
+ parts = rest.split("/")
355
+ if parts.size == 1
356
+ if alive
357
+ @nodes[rest] = true
358
+ else
359
+ @nodes.delete(rest)
360
+ end
361
+ elsif parts.size == 3
362
+ if alive
363
+ @alive[rest] = true
364
+ else
365
+ @alive.delete(rest)
366
+ end
367
+ end
368
+ end
369
+ end
370
+
371
+ # The node IDs alive now, this one first.
372
+ def self.nodes
373
+ return [] unless @session
374
+ list = [@node]
375
+ @nodes.each_key { |n| list << n unless list.include?(n) }
376
+ @alive.each_key do |path|
377
+ n = path.split("/")[0]
378
+ list << n unless list.include?(n)
379
+ end
380
+ list
381
+ end
382
+
383
+ # The exposed objects alive now whose <node>/<app>/<object> matches the
384
+ # pattern (* is one chunk, ** any number), this application's own
385
+ # included. With a block, yields a proxy for each; else returns them.
386
+ def self.each(pattern = "**")
387
+ paths = []
388
+ if @session
389
+ @objects.each_key do |name|
390
+ path = "#{@node}/#{@app}/#{name}"
391
+ paths << path if glob?(pattern, path)
392
+ end
393
+ @alive.each_key { |path| paths << path if glob?(pattern, path) && !paths.include?(path) }
394
+ end
395
+ proxies = paths.map { |path| self[path] }
396
+ return proxies unless block_given?
397
+ proxies.each { |px| yield px }
398
+ proxies.size
399
+ end
400
+
401
+ # A proxy for <node>/<app>/<object>. Nothing is sent until a method is
402
+ # called on it.
403
+ def self.[](path, timeout_ms = DEFAULT_TIMEOUT_MS)
404
+ split_path(path)
405
+ @proxies ||= {}
406
+ key = "#{path}|#{timeout_ms}"
407
+ @proxies[key] ||= Proxy.new(path.to_s, timeout_ms)
408
+ end
409
+
410
+ # ------------------------------------------------------------ calling
411
+
412
+ # Starts a call; returns a Future. Raises EncodeError (before sending)
413
+ # and Disconnected.
414
+ def self.call_async(path, method, args, kwargs, timeout_ms)
415
+ parts = split_path(path)
416
+ payload = Codec.pack([method.to_s, args, kwargs])
417
+ if parts[0] == @node && parts[1] == @app && @session
418
+ # This application's own object: no network (a session does not see
419
+ # its own queryable), same encoding and checks as a remote call.
420
+ return Future.answered(path, method, dispatch(parts[2], payload))
421
+ end
422
+ begin
423
+ g = session!.get("#{ROOT}/#{path}/call", timeout_ms, nil, payload)
424
+ rescue Asterism::Zenoh::Error => e
425
+ lost(e.message)
426
+ raise Disconnected, e.message
427
+ end
428
+ Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
429
+ end
430
+
431
+ def self.call(path, method, args, kwargs, timeout_ms)
432
+ call_async(path, method, args, kwargs, timeout_ms).value
433
+ end
434
+
435
+ # The meta reply of path: {"methods" => [[name, arity], ...]}.
436
+ def self.meta(path, timeout_ms = DEFAULT_TIMEOUT_MS)
437
+ parts = split_path(path)
438
+ if parts[0] == @node && parts[1] == @app && @session
439
+ m = meta_of(parts[2])
440
+ raise RemoteError.new("NameError", "no object #{parts[2]} exposed by #{@node}/#{@app}") unless m
441
+ return m
442
+ end
443
+ begin
444
+ g = session!.get("#{ROOT}/#{path}/meta", timeout_ms)
445
+ rescue Asterism::Zenoh::Error => e
446
+ lost(e.message)
447
+ raise Disconnected, e.message
448
+ end
449
+ f = Future.new(path, "meta", g, now_ms + timeout_ms, timeout_ms)
450
+ f.raw_value
451
+ end
452
+
453
+ # ------------------------------------------------------------ answering
454
+
455
+ def self.answer_calls
456
+ qa = @queryable
457
+ return unless qa
458
+ # Without a block, so that a handler that waits (and so polls) does not
459
+ # re-enter the queryable's iteration.
460
+ queries = qa.each_pending
461
+ i = 0
462
+ while i < queries.size
463
+ q = queries[i]
464
+ i += 1
465
+ begin
466
+ answer(q)
467
+ ensure
468
+ begin
469
+ q.finish
470
+ rescue Asterism::Zenoh::Error
471
+ # the session went away meanwhile
472
+ end
473
+ end
474
+ end
475
+ end
476
+
477
+ # asterism/<node>/<app>/<object>/<call|meta>; the node and app may come
478
+ # as wildcards (the query matched this application's queryable).
479
+ def self.answer(q)
480
+ parts = q.key.split("/")
481
+ return unless parts.size == 5
482
+ obj = parts[3]
483
+ kind = parts[4]
484
+ prefix = "#{ROOT}/#{@node}/#{@app}/"
485
+ if kind == "meta"
486
+ names = obj == "*" ? @objects.keys : [obj]
487
+ names.each do |n|
488
+ m = meta_of(n)
489
+ q.reply("#{prefix}#{n}/meta", Codec.pack(m)) if m
490
+ end
491
+ elsif kind == "call" && obj != "*"
492
+ q.reply("#{prefix}#{obj}/call", dispatch(obj, q.payload))
493
+ end
494
+ end
495
+
496
+ def self.meta_of(name)
497
+ entry = @objects[name]
498
+ return nil unless entry
499
+ list = []
500
+ entry[1].each { |m, n| list << [m, n] }
501
+ { "methods" => list }
502
+ end
503
+
504
+ # Runs a call on an exposed object; returns the packed reply.
505
+ def self.dispatch(obj_name, payload)
506
+ req = Codec.unpack(payload)
507
+ unless req.is_a?(Array) && req.size == 3 && req[0].is_a?(String) && req[1].is_a?(Array) && req[2].is_a?(Hash)
508
+ return error_reply("ArgumentError", "malformed call (expected [method, args, kwargs])")
509
+ end
510
+ entry = @objects[obj_name]
511
+ return error_reply("NameError", "no object #{obj_name} exposed by #{@node}/#{@app}") unless entry
512
+ name = req[0]
513
+ args = req[1]
514
+ kw = req[2]
515
+ arity = entry[1][name]
516
+ if arity.nil?
517
+ return error_reply("NoMethodError", "undefined method '#{name}' for #{@node}/#{@app}/#{obj_name} (not exposed)")
518
+ end
519
+ if arity >= 0 && args.size != arity
520
+ return error_reply("ArgumentError", "wrong number of arguments (given #{args.size}, expected #{arity})")
521
+ end
522
+ begin
523
+ value = invoke(entry[0], name, args, kw)
524
+ Codec.pack(["ok", value])
525
+ rescue EncodeError => e
526
+ error_reply("Asterism::EncodeError", "the return value: #{e.message}")
527
+ rescue => e
528
+ error_reply(e.class.to_s, e.message)
529
+ end
530
+ end
531
+
532
+ def self.invoke(obj, name, args, kw)
533
+ if kw.empty?
534
+ obj.public_send(name, *args)
535
+ else
536
+ opts = {}
537
+ kw.each { |k, v| opts[k.to_s.to_sym] = v }
538
+ obj.public_send(name, *args, **opts)
539
+ end
540
+ end
541
+
542
+ def self.error_reply(klass, message)
543
+ ::MessagePack.pack(["error", klass.to_s, message.to_s])
544
+ end
545
+ end