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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/NOTICE +27 -0
- data/README.md +405 -3
- data/data/msgs/LICENSE-Apache-2.0.txt +202 -0
- data/data/msgs/NOTICE +17 -0
- data/data/msgs/builtin_interfaces/msg/Duration.rb +33 -0
- data/data/msgs/builtin_interfaces/msg/Time.rb +33 -0
- data/data/msgs/example_interfaces/srv/AddTwoInts.rb +64 -0
- data/data/msgs/geometry_msgs/msg/Accel.rb +35 -0
- data/data/msgs/geometry_msgs/msg/AccelStamped.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Point.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Point32.rb +36 -0
- data/data/msgs/geometry_msgs/msg/PointStamped.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Pose.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Pose2D.rb +36 -0
- data/data/msgs/geometry_msgs/msg/PoseStamped.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Quaternion.rb +39 -0
- data/data/msgs/geometry_msgs/msg/QuaternionStamped.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Transform.rb +36 -0
- data/data/msgs/geometry_msgs/msg/TransformStamped.rb +39 -0
- data/data/msgs/geometry_msgs/msg/Twist.rb +35 -0
- data/data/msgs/geometry_msgs/msg/TwistStamped.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Vector3.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Vector3Stamped.rb +36 -0
- data/data/msgs/geometry_msgs/msg/Wrench.rb +35 -0
- data/data/msgs/geometry_msgs/msg/WrenchStamped.rb +36 -0
- data/data/msgs/sensor_msgs/msg/BatteryState.rb +100 -0
- data/data/msgs/sensor_msgs/msg/FluidPressure.rb +38 -0
- data/data/msgs/sensor_msgs/msg/Illuminance.rb +38 -0
- data/data/msgs/sensor_msgs/msg/Imu.rb +52 -0
- data/data/msgs/sensor_msgs/msg/JointState.rb +44 -0
- data/data/msgs/sensor_msgs/msg/MagneticField.rb +39 -0
- data/data/msgs/sensor_msgs/msg/NavSatFix.rb +55 -0
- data/data/msgs/sensor_msgs/msg/NavSatStatus.rb +43 -0
- data/data/msgs/sensor_msgs/msg/Range.rb +52 -0
- data/data/msgs/sensor_msgs/msg/RelativeHumidity.rb +38 -0
- data/data/msgs/sensor_msgs/msg/Temperature.rb +38 -0
- data/data/msgs/std_msgs/msg/Bool.rb +30 -0
- data/data/msgs/std_msgs/msg/Byte.rb +30 -0
- data/data/msgs/std_msgs/msg/ByteMultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/Char.rb +30 -0
- data/data/msgs/std_msgs/msg/ColorRGBA.rb +39 -0
- data/data/msgs/std_msgs/msg/Empty.rb +26 -0
- data/data/msgs/std_msgs/msg/Float32.rb +30 -0
- data/data/msgs/std_msgs/msg/Float32MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/Float64.rb +30 -0
- data/data/msgs/std_msgs/msg/Float64MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/Header.rb +35 -0
- data/data/msgs/std_msgs/msg/Int16.rb +30 -0
- data/data/msgs/std_msgs/msg/Int16MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/Int32.rb +30 -0
- data/data/msgs/std_msgs/msg/Int32MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/Int64.rb +30 -0
- data/data/msgs/std_msgs/msg/Int64MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/Int8.rb +30 -0
- data/data/msgs/std_msgs/msg/Int8MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/MultiArrayDimension.rb +36 -0
- data/data/msgs/std_msgs/msg/MultiArrayLayout.rb +35 -0
- data/data/msgs/std_msgs/msg/String.rb +30 -0
- data/data/msgs/std_msgs/msg/UInt16.rb +30 -0
- data/data/msgs/std_msgs/msg/UInt16MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/UInt32.rb +30 -0
- data/data/msgs/std_msgs/msg/UInt32MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/UInt64.rb +30 -0
- data/data/msgs/std_msgs/msg/UInt64MultiArray.rb +35 -0
- data/data/msgs/std_msgs/msg/UInt8.rb +30 -0
- data/data/msgs/std_msgs/msg/UInt8MultiArray.rb +35 -0
- data/lib/asterism/cruby.rb +24 -0
- data/lib/asterism/version.rb +5 -0
- data/lib/asterism.rb +17 -4
- data/mrblib/asterism.rb +545 -0
- data/mrblib/cdr.rb +512 -0
- data/mrblib/future.rb +83 -0
- data/mrblib/proxy.rb +97 -0
- data/mrblib/ros.rb +691 -0
- data/tools/asterism_msggen.rb +826 -0
- 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
|