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