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
data/mrblib/ros.rb ADDED
@@ -0,0 +1,691 @@
1
+ # Asterism::ROS: a minimal ROS 2 node over rmw_zenoh's wire format
2
+ # (doc/ruby_asterism/design.md ch. 5; topics R1, services R2, message types
3
+ # R3). The message and service types are generated from .msg / .srv files
4
+ # (tools/asterism_msggen.rb) and loaded when the application asks for them.
5
+ #
6
+ # s = Asterism::Zenoh::Session.open("tcp/192.0.2.2:7447")
7
+ # node = Asterism::ROS::Node.new(s, "fmruby_talker")
8
+ # str = Asterism::ROS.require_type("std_msgs/msg/String")
9
+ # pub = node.publisher("/chatter", str)
10
+ # sub = node.subscription("/chatter_back", "std_msgs/msg/String") # by name
11
+ # add = Asterism::ROS.require_type("example_interfaces/srv/AddTwoInts")
12
+ # node.service("/add_two_ints_fmrb", add) { |req| { sum: req.a + req.b } }
13
+ # cli = node.client("/add_two_ints", add)
14
+ # loop do
15
+ # node.poll # session.poll + answer services
16
+ # pub << { data: "hello" } # or pub.publish(str.new(data: "hello"))
17
+ # sub.each_pending { |msg, info| puts msg.data } # info: Attachment or nil
18
+ # end
19
+ # cli.call(a: 2, b: 3).sum # waits (polling), or
20
+ # c = cli.call_async(a: 2, b: 3); c.done?; c.value
21
+ # node.close
22
+ #
23
+ # What goes on the wire (rmw_zenoh_cpp 0.2.x, ROS 2 Jazzy):
24
+ # - data key: <domain>/<topic without the outer slashes>/<DDS type name>/<type hash>
25
+ # e.g. 0/chatter/std_msgs::msg::dds_::String_/RIHS01_df66...
26
+ # - payload: CDR (Asterism::CDR)
27
+ # - attachment (required by rmw_zenoh subscribers; a sample without one is
28
+ # dropped): int64 sequence number, int64 source time (ns since the epoch),
29
+ # both little endian, then the GID as a one-byte length (16) and 16 bytes.
30
+ # - liveliness tokens, which make the node and its topics appear in
31
+ # `ros2 node list` / `ros2 topic list`:
32
+ # @ros2_lv/<domain>/<zid>/<nid>/<nid>/NN/<enclave>/<namespace>/<node>
33
+ # @ros2_lv/<domain>/<zid>/<nid>/<id>/MP|MS/<enclave>/<namespace>/<node>/
34
+ # <topic>/<DDS type name>/<type hash>/<qos>
35
+ # with "/" written as "%" in enclave, namespace and topic ("%" alone for "/").
36
+ # - services: the server is a complete queryable on
37
+ # <domain>/<service>/<DDS service type>/<service type hash>, the client
38
+ # gets that key with target ALL_COMPLETE and no consolidation. Request and
39
+ # reply both carry the attachment above: the client's sequence number and
40
+ # GID go out with the request, and the reply carries them back (with the
41
+ # server's time), which is how the client pairs them. Tokens SS (server)
42
+ # and SC (client), shaped like MP / MS.
43
+ #
44
+ # Nothing here depends on the host: it uses an Asterism::Zenoh::Session
45
+ # (put with attachment:, subscribe, liveliness, zid) and Asterism::CDR.
46
+ module Asterism
47
+ module ROS
48
+ LIVELINESS_ROOT = "@ros2_lv"
49
+ # rmw_zenoh's QoS chunk for the default profile (reliable, volatile,
50
+ # keep last 10): fields that equal the default are left empty.
51
+ DEFAULT_QOS = "::,10:,:,:,,"
52
+ GID_SIZE = 16
53
+
54
+ # A name chunk of a liveliness token: "/" becomes "%", "" or "/" is "%".
55
+ def self.mangle(name)
56
+ s = name.to_s
57
+ return "%" if s.empty? || s == "/"
58
+ out = ""
59
+ i = 0
60
+ n = s.bytesize
61
+ while i < n
62
+ c = s.byteslice(i, 1)
63
+ out << (c == "/" ? "%" : c)
64
+ i += 1
65
+ end
66
+ out
67
+ end
68
+
69
+ # Drops one leading and one trailing "/".
70
+ def self.strip_slashes(name)
71
+ s = name.to_s
72
+ s = s.byteslice(1, s.bytesize - 1) if s.bytesize > 0 && s.byteslice(0, 1) == "/"
73
+ s = s.byteslice(0, s.bytesize - 1) if s.bytesize > 0 && s.byteslice(s.bytesize - 1, 1) == "/"
74
+ s
75
+ end
76
+
77
+ # A full topic name: absolute names stay, relative ones go under the
78
+ # namespace. "~" and substitutions are not supported.
79
+ def self.resolve(topic, namespace)
80
+ t = topic.to_s
81
+ raise ArgumentError, "empty topic name" if t.empty?
82
+ return t if t.byteslice(0, 1) == "/"
83
+ ns = namespace.to_s
84
+ ns == "/" || ns.empty? ? "/" + t : ns + "/" + t
85
+ end
86
+
87
+ # Nanoseconds since the epoch, or 0 when there is no clock.
88
+ def self.now_ns
89
+ if Object.const_defined?(:Time)
90
+ t = ::Time.now
91
+ t.to_i * 1_000_000_000 + t.usec * 1000
92
+ else
93
+ 0
94
+ end
95
+ rescue
96
+ 0
97
+ end
98
+
99
+ # 16 bytes derived from a string (32-bit FNV-1a, four lanes with
100
+ # different seeds; small enough not to overflow a 64-bit Integer).
101
+ # rmw_zenoh hashes the entity's liveliness key the same way (with XXH3);
102
+ # receivers only use the GID to tell publishers apart.
103
+ def self.gid_for(key)
104
+ out = ""
105
+ lane = 0
106
+ while lane < 4
107
+ h = 0x811c9dc5 ^ (lane * 0x9e3779b9 & 0xffffffff)
108
+ i = 0
109
+ n = key.bytesize
110
+ while i < n
111
+ h = ((h ^ key.getbyte(i)) * 0x01000193) & 0xffffffff
112
+ i += 1
113
+ end
114
+ out << ::Asterism::CDR.le_bytes(h, 4)
115
+ lane += 1
116
+ end
117
+ out
118
+ end
119
+
120
+ # A service call got no answer in time (or nobody serves that name).
121
+ class Timeout < ::StandardError; end
122
+
123
+ # Milliseconds from an arbitrary origin (the board's clock when there is
124
+ # one), and a short pause; the same helpers Asterism's calls use.
125
+ def self.now_ms
126
+ ::Asterism.now_ms
127
+ end
128
+
129
+ def self.pause(ms)
130
+ ::Asterism.pause(ms)
131
+ end
132
+
133
+ # The attachment of a sample: sequence number, source time, publisher GID.
134
+ class Attachment
135
+ attr_reader :sequence, :stamp_ns, :gid
136
+
137
+ def initialize(sequence, stamp_ns, gid)
138
+ @sequence = sequence
139
+ @stamp_ns = stamp_ns
140
+ @gid = gid
141
+ end
142
+
143
+ def encode
144
+ g = @gid.to_s
145
+ ::Asterism::CDR.le_bytes(@sequence, 8) + ::Asterism::CDR.le_bytes(@stamp_ns, 8) +
146
+ ::Asterism::CDR.le_bytes(g.bytesize, 1) + g
147
+ end
148
+
149
+ # nil when the bytes are not an attachment of this shape.
150
+ def self.decode(bytes)
151
+ return nil if bytes.nil? || bytes.bytesize < 17
152
+ glen = bytes.getbyte(16)
153
+ return nil if bytes.bytesize < 17 + glen
154
+ new(le_int(bytes, 0), le_int(bytes, 8), bytes.byteslice(17, glen))
155
+ end
156
+
157
+ def self.le_int(bytes, at)
158
+ top = bytes.getbyte(at + 7)
159
+ v = top >= 0x80 ? top - 0x100 : top
160
+ i = 6
161
+ while i >= 0
162
+ v = (v << 8) | bytes.getbyte(at + i)
163
+ i -= 1
164
+ end
165
+ v
166
+ end
167
+ end
168
+
169
+ # Where Asterism::ROS.require_type looks for generated types
170
+ # (<dir>/<pkg>/<msg|srv>/<Name>.rb, tools/asterism_msggen.rb). The
171
+ # application may add its own directories (or set them on CRuby).
172
+ TYPE_PATH = ["/usr/share/asterism/msgs"]
173
+
174
+ # The type has no generated file in TYPE_PATH.
175
+ class UnknownType < ::StandardError; end
176
+
177
+ def self.type_path
178
+ TYPE_PATH
179
+ end
180
+
181
+ # Loads a generated message or service type by its ROS name
182
+ # ("geometry_msgs/msg/Twist", "example_interfaces/srv/AddTwoInts") and
183
+ # returns it (Asterism::ROS::GeometryMsgs::Twist). The file loads the
184
+ # types it is made of first. A type already defined (loaded before, or
185
+ # written in Ruby by the application) is returned without a file.
186
+ def self.require_type(name)
187
+ parts = name.to_s.split("/")
188
+ if parts.size != 3 || (parts[1] != "msg" && parts[1] != "srv")
189
+ raise ArgumentError, "bad type name #{name.inspect} (want pkg/msg/Name or pkg/srv/Name)"
190
+ end
191
+ t = type_constant(parts[0], parts[2])
192
+ return t if t
193
+ found = nil
194
+ TYPE_PATH.each do |dir|
195
+ path = "#{dir}/#{parts[0]}/#{parts[1]}/#{parts[2]}.rb"
196
+ found = path if found.nil? && File.exist?(path)
197
+ end
198
+ load_type_file(found) if found
199
+ t = type_constant(parts[0], parts[2])
200
+ raise UnknownType, "no generated type #{name} in #{TYPE_PATH.join(', ')}" if t.nil?
201
+ t
202
+ end
203
+
204
+ # On mruby the file is evaluated in this VM (Kernel#eval): unlike
205
+ # require, which runs each file in a Sandbox task of its own that stays
206
+ # for the life of the VM, this keeps only the compiled code (measured in
207
+ # report/r3.md). CRuby requires it.
208
+ def self.load_type_file(path)
209
+ if RUBY_ENGINE == "ruby"
210
+ require File.expand_path(path)
211
+ else
212
+ src = File.open(path, "r") { |f| f.read }
213
+ eval(src)
214
+ end
215
+ nil
216
+ end
217
+
218
+ # Asterism::ROS::<Pkg>::<Name>, or nil.
219
+ def self.type_constant(pkg, name)
220
+ mod_name = ""
221
+ pkg.split("_").each do |w|
222
+ mod_name << w.byteslice(0, 1).upcase << w.byteslice(1, w.bytesize - 1) if w.bytesize > 0
223
+ end
224
+ mod_sym = mod_name.to_sym
225
+ return nil unless ::Asterism::ROS.const_defined?(mod_sym, false)
226
+ mod = ::Asterism::ROS.const_get(mod_sym)
227
+ sym = name.to_sym
228
+ return nil unless mod.const_defined?(sym, false)
229
+ mod.const_get(sym)
230
+ end
231
+
232
+ # A type given as a generated type or its ROS name (String).
233
+ def self.type_of(type)
234
+ type.is_a?(::String) ? require_type(type) : type
235
+ end
236
+
237
+ # For the generated types' from: a Hash of fields with Symbol keys
238
+ # (String keys are turned into Symbols). Anything else raises TypeError.
239
+ def self.fields_of(v, type)
240
+ raise TypeError, "#{type::ROS_NAME} from #{v.class}: want a Hash or a #{type::ROS_NAME}" unless v.is_a?(::Hash)
241
+ out = {}
242
+ v.each { |k, x| out[k.is_a?(::Symbol) ? k : k.to_s.to_sym] = x }
243
+ out
244
+ end
245
+
246
+ # The base of the generated message types (Request / Response of a
247
+ # service too). A type adds ROS_NAME, TYPE_NAME, TYPE_HASH, FIELDS, an
248
+ # accessor per field, initialize(**fields) and the CDR steps
249
+ # write(w, msg) / read(r); the rest is here.
250
+ class Message
251
+ # A message from nil (all defaults), a Hash of fields (Symbol or
252
+ # String keys; missing fields take their defaults, unknown ones raise
253
+ # ArgumentError) or a message of this type (itself).
254
+ def self.from(v)
255
+ return v if v.is_a?(self)
256
+ return new if v.nil?
257
+ new(**::Asterism::ROS.fields_of(v, self))
258
+ end
259
+
260
+ # CDR bytes (with the 4-byte header) of a message or a Hash.
261
+ def self.encode(msg)
262
+ w = ::Asterism::CDR::Writer.new
263
+ write(w, from(msg))
264
+ w.to_s
265
+ end
266
+
267
+ def self.decode(bytes)
268
+ read(::Asterism::CDR::Reader.new(bytes))
269
+ end
270
+
271
+ # The fields as a Hash (nested messages as Hashes too).
272
+ def to_h
273
+ h = {}
274
+ fs = self.class::FIELDS
275
+ i = 0
276
+ while i < fs.size
277
+ h[fs[i]] = ::Asterism::ROS::Message.plain(__send__(fs[i]))
278
+ i += 1
279
+ end
280
+ h
281
+ end
282
+
283
+ def self.plain(v)
284
+ if v.is_a?(::Asterism::ROS::Message)
285
+ v.to_h
286
+ elsif v.is_a?(::Array) && v.size > 0 && v[0].is_a?(::Asterism::ROS::Message)
287
+ v.map { |e| e.to_h }
288
+ else
289
+ v
290
+ end
291
+ end
292
+
293
+ def ==(other)
294
+ other.class == self.class && to_h == other.to_h
295
+ end
296
+
297
+ def inspect
298
+ "#<#{self.class::ROS_NAME} #{to_h.inspect}>"
299
+ end
300
+
301
+ alias to_s inspect
302
+ end
303
+
304
+ class Node
305
+ attr_reader :name, :namespace, :domain, :zid, :key, :session
306
+
307
+ # session: an open Asterism::Zenoh::Session. Declares the node's
308
+ # liveliness token at once.
309
+ def initialize(session, name, namespace: "/", domain: 0, enclave: "/")
310
+ @session = session
311
+ @name = name.to_s
312
+ raise ArgumentError, "bad node name #{@name.inspect}" if @name.empty? || @name.include?("/")
313
+ @namespace = namespace.to_s
314
+ @domain = domain.to_i
315
+ @enclave = enclave.to_s
316
+ @zid = session.zid
317
+ @nid = 0
318
+ @next_id = 1
319
+ @entities = []
320
+ @services = []
321
+ @key = "#{LIVELINESS_ROOT}/#{@domain}/#{@zid}/#{@nid}/#{@nid}/NN/" \
322
+ "#{::Asterism::ROS.mangle(@enclave)}/#{::Asterism::ROS.mangle(@namespace)}/#{@name}"
323
+ @token = session.liveliness(@key)
324
+ end
325
+
326
+ def publisher(topic, type, qos: DEFAULT_QOS)
327
+ e = ::Asterism::ROS::Publisher.new(self, @session, topic, ::Asterism::ROS.type_of(type), qos)
328
+ @entities << e
329
+ e
330
+ end
331
+
332
+ def subscription(topic, type, qos: DEFAULT_QOS, depth: 16)
333
+ e = ::Asterism::ROS::Subscription.new(self, @session, topic, ::Asterism::ROS.type_of(type), qos, depth)
334
+ @entities << e
335
+ e
336
+ end
337
+
338
+ # Serves `service` (a name) with the block: it gets the request (an
339
+ # object of type::Request) and returns the response (a type::Response
340
+ # or a Hash of its fields). The block runs from node.poll (or
341
+ # service.handle_pending), never behind the application's back.
342
+ def service(service, type, qos: DEFAULT_QOS, depth: 8, &handler)
343
+ raise ArgumentError, "service needs a block" unless handler
344
+ e = ::Asterism::ROS::Service.new(self, @session, service, ::Asterism::ROS.type_of(type), qos, depth, handler)
345
+ @entities << e
346
+ @services << e
347
+ e
348
+ end
349
+
350
+ def client(service, type, qos: DEFAULT_QOS)
351
+ e = ::Asterism::ROS::Client.new(self, @session, service, ::Asterism::ROS.type_of(type), qos)
352
+ @entities << e
353
+ e
354
+ end
355
+
356
+ # node.call("/add_two_ints", AddTwoInts, a: 1, b: 2) -> response.
357
+ # A client per service name is made on first use and kept.
358
+ def call(service, type, request = nil, timeout_ms: ::Asterism::ROS::Client::DEFAULT_TIMEOUT_MS, **fields)
359
+ @clients ||= {}
360
+ c = @clients[service]
361
+ if c.nil? || c.closed?
362
+ c = client(service, type)
363
+ @clients[service] = c
364
+ end
365
+ c.call(request, timeout_ms: timeout_ms, **fields)
366
+ end
367
+
368
+ # Polls the session (Asterism::Zenoh::Session#poll) and answers the
369
+ # requests that came in for this node's services. Returns what
370
+ # session.poll returns (false once the session is closed).
371
+ def poll(steps = 8)
372
+ ok = @session.poll(steps)
373
+ @services.each { |sv| sv.handle_pending }
374
+ ok
375
+ end
376
+
377
+ # Withdraws the node and its publishers, subscriptions, services and
378
+ # clients. Idempotent.
379
+ def close
380
+ @entities.each { |e| e.close }
381
+ @entities = []
382
+ @services = []
383
+ @clients = {}
384
+ @token.close if @token
385
+ @token = nil
386
+ nil
387
+ end
388
+
389
+ def closed?
390
+ @token.nil?
391
+ end
392
+
393
+ # For the entities: [data key, liveliness token key] of a topic.
394
+ def entity_keys(kind, topic, type, qos)
395
+ full = ::Asterism::ROS.resolve(topic, @namespace)
396
+ data = "#{@domain}/#{::Asterism::ROS.strip_slashes(full)}/#{type::TYPE_NAME}/#{type::TYPE_HASH}"
397
+ id = @next_id
398
+ @next_id += 1
399
+ token = "#{LIVELINESS_ROOT}/#{@domain}/#{@zid}/#{@nid}/#{id}/#{kind}/" \
400
+ "#{::Asterism::ROS.mangle(@enclave)}/#{::Asterism::ROS.mangle(@namespace)}/#{@name}/" \
401
+ "#{::Asterism::ROS.mangle(full)}/#{type::TYPE_NAME}/#{type::TYPE_HASH}/#{qos}"
402
+ [data, token]
403
+ end
404
+ end
405
+
406
+ class Publisher
407
+ attr_reader :topic_key, :token_key, :gid, :sequence
408
+
409
+ def initialize(node, session, topic, type, qos)
410
+ @session = session
411
+ @type = type
412
+ @topic_key, @token_key = node.entity_keys("MP", topic, type, qos)
413
+ @gid = ::Asterism::ROS.gid_for(@token_key)
414
+ @sequence = 0
415
+ @token = session.liveliness(@token_key)
416
+ end
417
+
418
+ # msg: a message of the type, or a Hash of its fields (missing ones
419
+ # take their defaults).
420
+ def publish(msg)
421
+ raise ::Asterism::Zenoh::Error, "publisher closed" if @token.nil?
422
+ @sequence += 1
423
+ att = ::Asterism::ROS::Attachment.new(@sequence, ::Asterism::ROS.now_ns, @gid)
424
+ @session.put(@topic_key, @type.encode(msg), attachment: att.encode)
425
+ nil
426
+ end
427
+
428
+ # pub << { linear: { x: 0.1 } }
429
+ def <<(msg)
430
+ publish(msg)
431
+ self
432
+ end
433
+
434
+ def close
435
+ @token.close if @token
436
+ @token = nil
437
+ nil
438
+ end
439
+ end
440
+
441
+ class Subscription
442
+ attr_reader :topic_key, :token_key, :errors
443
+
444
+ def initialize(node, session, topic, type, qos, depth)
445
+ @type = type
446
+ @topic_key, @token_key = node.entity_keys("MS", topic, type, qos)
447
+ @errors = 0
448
+ @sub = session.subscribe(@topic_key, depth)
449
+ @token = session.liveliness(@token_key)
450
+ end
451
+
452
+ # Yields each message received so far (decoded) and its Attachment (nil
453
+ # when the sample had none). Samples that do not decode are counted in
454
+ # errors and skipped. Returns the number yielded; without a block, an
455
+ # Array of [msg, attachment].
456
+ def each_pending
457
+ out = []
458
+ @sub.each_pending.each do |entry|
459
+ msg = begin
460
+ @type.decode(entry[1])
461
+ rescue ::Asterism::CDR::DecodeError
462
+ @errors += 1
463
+ nil
464
+ end
465
+ next if msg.nil?
466
+ out << [msg, ::Asterism::ROS::Attachment.decode(entry[2])]
467
+ end
468
+ return out unless block_given?
469
+ out.each { |m| yield m[0], m[1] }
470
+ out.size
471
+ end
472
+
473
+ def dropped
474
+ @sub.dropped
475
+ end
476
+
477
+ def close
478
+ @sub.close
479
+ @token.close if @token
480
+ @token = nil
481
+ nil
482
+ end
483
+ end
484
+ end
485
+ end
486
+
487
+ module Asterism
488
+ module ROS
489
+ # The server side of a service (node.service). A complete queryable on
490
+ # the service key, plus the SS token.
491
+ class Service
492
+ attr_reader :service_key, :token_key, :handled, :errors
493
+
494
+ def initialize(node, session, service, type, qos, depth, handler)
495
+ @type = type
496
+ @handler = handler
497
+ @service_key, @token_key = node.entity_keys("SS", service, type, qos)
498
+ @handled = 0
499
+ @errors = 0
500
+ @queryable = session.queryable(@service_key, depth, complete: true)
501
+ @token = session.liveliness(@token_key)
502
+ end
503
+
504
+ # Answers the requests waiting now with the block given to
505
+ # node.service. A request that cannot be decoded, or has no attachment
506
+ # (rmw_zenoh clients always send one), is counted in errors and
507
+ # finished without an answer. An exception from the block finishes
508
+ # that request without an answer and is raised from here. Returns the
509
+ # number answered.
510
+ def handle_pending
511
+ return 0 if @token.nil?
512
+ n = 0
513
+ # The Array form: no block called from C (stack depth, see README).
514
+ qs = @queryable.each_pending
515
+ begin
516
+ qs.each do |q|
517
+ n += 1 if answer(q)
518
+ q.finish
519
+ end
520
+ ensure
521
+ # Also the ones after a request whose block raised (finish is
522
+ # idempotent; an unanswered query just ends for the client).
523
+ qs.each { |q| q.finish }
524
+ end
525
+ n
526
+ end
527
+
528
+ def close
529
+ @queryable.close
530
+ @token.close if @token
531
+ @token = nil
532
+ nil
533
+ end
534
+
535
+ def closed?
536
+ @token.nil?
537
+ end
538
+
539
+ private
540
+
541
+ def answer(q)
542
+ info = ::Asterism::ROS::Attachment.decode(q.attachment)
543
+ req = nil
544
+ begin
545
+ req = @type::Request.decode(q.payload) if info
546
+ rescue ::Asterism::CDR::DecodeError
547
+ req = nil
548
+ end
549
+ if req.nil?
550
+ @errors += 1
551
+ return false
552
+ end
553
+ res = @handler.call(req)
554
+ # The client's sequence number and GID go back with the reply.
555
+ att = ::Asterism::ROS::Attachment.new(info.sequence, ::Asterism::ROS.now_ns, info.gid)
556
+ q.reply(@service_key, @type::Response.encode(res), attachment: att.encode)
557
+ @handled += 1
558
+ true
559
+ end
560
+ end
561
+
562
+ # A call on its way (client.call_async). done? never waits (node.poll
563
+ # moves it on); value waits, polling the node's session.
564
+ class Call
565
+ attr_reader :sequence, :response, :took_ms
566
+
567
+ def initialize(client, get, sequence, timeout_ms)
568
+ @client = client
569
+ @get = get
570
+ @sequence = sequence
571
+ @timeout_ms = timeout_ms
572
+ @started = ::Asterism::ROS.now_ms
573
+ @deadline = @started + timeout_ms
574
+ @response = nil
575
+ @finished = false
576
+ @took_ms = nil
577
+ @nobody = false
578
+ end
579
+
580
+ # True once the response came, the time ran out, or no server answered.
581
+ def done?
582
+ collect
583
+ @finished
584
+ end
585
+
586
+ # The response (type::Response); waits until done. Raises
587
+ # Asterism::ROS::Timeout when there was none.
588
+ def value
589
+ @client.wait_for(self) unless done?
590
+ if @response.nil?
591
+ raise ::Asterism::Zenoh::Error, "session is closed" if @client.session_closed?
592
+ if @nobody
593
+ raise ::Asterism::ROS::Timeout, "no answer from #{@client.service_name} (nobody serves it)"
594
+ end
595
+ raise ::Asterism::ROS::Timeout, "no answer from #{@client.service_name} within #{@timeout_ms} ms"
596
+ end
597
+ @response
598
+ end
599
+
600
+ def collect
601
+ return if @finished
602
+ g = @get
603
+ # The Array form, as in Asterism::Future (no block called from C).
604
+ g.each_reply.each do |r|
605
+ next unless @response.nil?
606
+ info = ::Asterism::ROS::Attachment.decode(r[2])
607
+ # rmw_zenoh pairs the reply by the sequence number it carries back.
608
+ next if info.nil? || info.sequence != @sequence
609
+ begin
610
+ @response = @client.decode_response(r[1])
611
+ rescue ::Asterism::CDR::DecodeError
612
+ @response = nil
613
+ end
614
+ end
615
+ now = ::Asterism::ROS.now_ms
616
+ if !@response.nil? || g.done? || now >= @deadline
617
+ @nobody = @response.nil? && now < @deadline
618
+ @finished = true
619
+ @took_ms = now - @started
620
+ end
621
+ end
622
+ end
623
+
624
+ # The client side of a service (node.client). Sends each request as a
625
+ # get on the service key, plus the SC token.
626
+ class Client
627
+ DEFAULT_TIMEOUT_MS = 2000
628
+ # Pause between polls while a call waits (ms).
629
+ WAIT_STEP_MS = 2
630
+
631
+ attr_reader :service_key, :token_key, :gid, :sequence, :service_name
632
+
633
+ def initialize(node, session, service, type, qos)
634
+ @node = node
635
+ @session = session
636
+ @type = type
637
+ @service_name = ::Asterism::ROS.resolve(service, node.namespace)
638
+ @service_key, @token_key = node.entity_keys("SC", service, type, qos)
639
+ @gid = ::Asterism::ROS.gid_for(@token_key)
640
+ @sequence = 0
641
+ @token = session.liveliness(@token_key)
642
+ end
643
+
644
+ # Sends the request and returns a Call at once. request: a
645
+ # type::Request, a Hash, or the fields as keywords.
646
+ def call_async(request = nil, timeout_ms: DEFAULT_TIMEOUT_MS, **fields)
647
+ raise ::Asterism::Zenoh::Error, "client closed" if @token.nil?
648
+ req = request.nil? ? fields : request
649
+ payload = @type::Request.encode(req)
650
+ @sequence += 1
651
+ att = ::Asterism::ROS::Attachment.new(@sequence, ::Asterism::ROS.now_ns, @gid)
652
+ g = @session.get(@service_key, timeout_ms, nil, payload, attachment: att.encode,
653
+ target: :all_complete, consolidation: :none)
654
+ ::Asterism::ROS::Call.new(self, g, @sequence, timeout_ms)
655
+ end
656
+
657
+ # Sends the request and waits for the response (polling the node, so
658
+ # this node's services keep answering meanwhile). Raises
659
+ # Asterism::ROS::Timeout when no response came in time.
660
+ def call(request = nil, timeout_ms: DEFAULT_TIMEOUT_MS, **fields)
661
+ call_async(request, timeout_ms: timeout_ms, **fields).value
662
+ end
663
+
664
+ def wait_for(c)
665
+ until c.done?
666
+ break unless @node.poll
667
+ break if c.done?
668
+ ::Asterism::ROS.pause(WAIT_STEP_MS)
669
+ end
670
+ end
671
+
672
+ def session_closed?
673
+ @session.closed?
674
+ end
675
+
676
+ def decode_response(bytes)
677
+ @type::Response.decode(bytes)
678
+ end
679
+
680
+ def close
681
+ @token.close if @token
682
+ @token = nil
683
+ nil
684
+ end
685
+
686
+ def closed?
687
+ @token.nil?
688
+ end
689
+ end
690
+ end
691
+ end