asterism 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/NOTICE +27 -0
  4. data/README.md +405 -3
  5. data/data/msgs/LICENSE-Apache-2.0.txt +202 -0
  6. data/data/msgs/NOTICE +17 -0
  7. data/data/msgs/builtin_interfaces/msg/Duration.rb +33 -0
  8. data/data/msgs/builtin_interfaces/msg/Time.rb +33 -0
  9. data/data/msgs/example_interfaces/srv/AddTwoInts.rb +64 -0
  10. data/data/msgs/geometry_msgs/msg/Accel.rb +35 -0
  11. data/data/msgs/geometry_msgs/msg/AccelStamped.rb +36 -0
  12. data/data/msgs/geometry_msgs/msg/Point.rb +36 -0
  13. data/data/msgs/geometry_msgs/msg/Point32.rb +36 -0
  14. data/data/msgs/geometry_msgs/msg/PointStamped.rb +36 -0
  15. data/data/msgs/geometry_msgs/msg/Pose.rb +36 -0
  16. data/data/msgs/geometry_msgs/msg/Pose2D.rb +36 -0
  17. data/data/msgs/geometry_msgs/msg/PoseStamped.rb +36 -0
  18. data/data/msgs/geometry_msgs/msg/Quaternion.rb +39 -0
  19. data/data/msgs/geometry_msgs/msg/QuaternionStamped.rb +36 -0
  20. data/data/msgs/geometry_msgs/msg/Transform.rb +36 -0
  21. data/data/msgs/geometry_msgs/msg/TransformStamped.rb +39 -0
  22. data/data/msgs/geometry_msgs/msg/Twist.rb +35 -0
  23. data/data/msgs/geometry_msgs/msg/TwistStamped.rb +36 -0
  24. data/data/msgs/geometry_msgs/msg/Vector3.rb +36 -0
  25. data/data/msgs/geometry_msgs/msg/Vector3Stamped.rb +36 -0
  26. data/data/msgs/geometry_msgs/msg/Wrench.rb +35 -0
  27. data/data/msgs/geometry_msgs/msg/WrenchStamped.rb +36 -0
  28. data/data/msgs/sensor_msgs/msg/BatteryState.rb +100 -0
  29. data/data/msgs/sensor_msgs/msg/FluidPressure.rb +38 -0
  30. data/data/msgs/sensor_msgs/msg/Illuminance.rb +38 -0
  31. data/data/msgs/sensor_msgs/msg/Imu.rb +52 -0
  32. data/data/msgs/sensor_msgs/msg/JointState.rb +44 -0
  33. data/data/msgs/sensor_msgs/msg/MagneticField.rb +39 -0
  34. data/data/msgs/sensor_msgs/msg/NavSatFix.rb +55 -0
  35. data/data/msgs/sensor_msgs/msg/NavSatStatus.rb +43 -0
  36. data/data/msgs/sensor_msgs/msg/Range.rb +52 -0
  37. data/data/msgs/sensor_msgs/msg/RelativeHumidity.rb +38 -0
  38. data/data/msgs/sensor_msgs/msg/Temperature.rb +38 -0
  39. data/data/msgs/std_msgs/msg/Bool.rb +30 -0
  40. data/data/msgs/std_msgs/msg/Byte.rb +30 -0
  41. data/data/msgs/std_msgs/msg/ByteMultiArray.rb +35 -0
  42. data/data/msgs/std_msgs/msg/Char.rb +30 -0
  43. data/data/msgs/std_msgs/msg/ColorRGBA.rb +39 -0
  44. data/data/msgs/std_msgs/msg/Empty.rb +26 -0
  45. data/data/msgs/std_msgs/msg/Float32.rb +30 -0
  46. data/data/msgs/std_msgs/msg/Float32MultiArray.rb +35 -0
  47. data/data/msgs/std_msgs/msg/Float64.rb +30 -0
  48. data/data/msgs/std_msgs/msg/Float64MultiArray.rb +35 -0
  49. data/data/msgs/std_msgs/msg/Header.rb +35 -0
  50. data/data/msgs/std_msgs/msg/Int16.rb +30 -0
  51. data/data/msgs/std_msgs/msg/Int16MultiArray.rb +35 -0
  52. data/data/msgs/std_msgs/msg/Int32.rb +30 -0
  53. data/data/msgs/std_msgs/msg/Int32MultiArray.rb +35 -0
  54. data/data/msgs/std_msgs/msg/Int64.rb +30 -0
  55. data/data/msgs/std_msgs/msg/Int64MultiArray.rb +35 -0
  56. data/data/msgs/std_msgs/msg/Int8.rb +30 -0
  57. data/data/msgs/std_msgs/msg/Int8MultiArray.rb +35 -0
  58. data/data/msgs/std_msgs/msg/MultiArrayDimension.rb +36 -0
  59. data/data/msgs/std_msgs/msg/MultiArrayLayout.rb +35 -0
  60. data/data/msgs/std_msgs/msg/String.rb +30 -0
  61. data/data/msgs/std_msgs/msg/UInt16.rb +30 -0
  62. data/data/msgs/std_msgs/msg/UInt16MultiArray.rb +35 -0
  63. data/data/msgs/std_msgs/msg/UInt32.rb +30 -0
  64. data/data/msgs/std_msgs/msg/UInt32MultiArray.rb +35 -0
  65. data/data/msgs/std_msgs/msg/UInt64.rb +30 -0
  66. data/data/msgs/std_msgs/msg/UInt64MultiArray.rb +35 -0
  67. data/data/msgs/std_msgs/msg/UInt8.rb +30 -0
  68. data/data/msgs/std_msgs/msg/UInt8MultiArray.rb +35 -0
  69. data/lib/asterism/cruby.rb +24 -0
  70. data/lib/asterism/version.rb +5 -0
  71. data/lib/asterism.rb +17 -4
  72. data/mrblib/asterism.rb +545 -0
  73. data/mrblib/cdr.rb +512 -0
  74. data/mrblib/future.rb +83 -0
  75. data/mrblib/proxy.rb +97 -0
  76. data/mrblib/ros.rb +691 -0
  77. data/tools/asterism_msggen.rb +826 -0
  78. metadata +118 -9
@@ -0,0 +1,826 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # asterism_msggen.rb: ROS 2 .msg / .srv -> pure Ruby message types for
5
+ # Asterism::ROS (doc/ruby_asterism, R3). CRuby with the standard library only.
6
+ #
7
+ # ruby asterism_msggen.rb -I /opt/ros/jazzy/share -o out geometry_msgs/msg/Twist
8
+ # ruby asterism_msggen.rb -I share -I my_ws/src -o out my_pkg/msg/Foo.msg
9
+ # ruby asterism_msggen.rb -I share --hash std_msgs/msg/String
10
+ # ruby asterism_msggen.rb -I share --check-json share --all
11
+ #
12
+ # Each generated file defines one type (a message, or a service with its
13
+ # Request and Response) under Asterism::ROS::<PackageInCamelCase>, with its
14
+ # fields and defaults, ROS_NAME, TYPE_NAME (the DDS name rmw_zenoh uses),
15
+ # TYPE_HASH (RIHS01, computed here) and the CDR conversion in both directions
16
+ # on top of Asterism::CDR. A file loads the types it is made of through
17
+ # Asterism::ROS.require_type first. The output layout mirrors the ROS names:
18
+ # <out>/<pkg>/msg/<Name>.rb and <out>/<pkg>/srv/<Name>.rb.
19
+ #
20
+ # The type hash follows the type description rules of ROS 2 Jazzy
21
+ # (rosidl_generator_type_description, REP 2011): the type and every type it
22
+ # references, as {type_name, fields: [{name, type: {type_id, capacity,
23
+ # string_capacity, nested_type_name}}]}, without default values, referenced
24
+ # types sorted by name, dumped like Python's json.dumps with separators
25
+ # (", ", ": ") and ensure_ascii, then SHA-256.
26
+ require "digest"
27
+ require "fileutils"
28
+ require "json"
29
+ require "optparse"
30
+
31
+ module AsterismMsgGen
32
+ ROS_DISTRO = "jazzy"
33
+
34
+ # .msg primitive -> [type description id, CDR kind used by Asterism::CDR]
35
+ PRIMITIVES = {
36
+ "bool" => [15, :bool],
37
+ "byte" => [16, :uint8],
38
+ "char" => [3, :uint8], # .msg char is uint8 in the IDL
39
+ "int8" => [2, :int8],
40
+ "uint8" => [3, :uint8],
41
+ "int16" => [4, :int16],
42
+ "uint16" => [5, :uint16],
43
+ "int32" => [6, :int32],
44
+ "uint32" => [7, :uint32],
45
+ "int64" => [8, :int64],
46
+ "uint64" => [9, :uint64],
47
+ "float32" => [10, :float32],
48
+ "float64" => [11, :float64],
49
+ "string" => [17, :string],
50
+ "wstring" => [18, :wstring]
51
+ }.freeze
52
+ NESTED_ID = 1
53
+ BOUNDED_STRING_ID = 21
54
+ BOUNDED_WSTRING_ID = 22
55
+ ARRAY_OFFSET = { fixed: 48, bounded: 96, unbounded: 144 }.freeze
56
+ # Kinds that are Strings of bytes when they come as an array or sequence.
57
+ BYTE_KINDS = %w[byte char uint8].freeze
58
+ FLOAT_KINDS = %w[float32 float64].freeze
59
+ INT_KINDS = %w[int8 uint8 int16 uint16 int32 uint32 int64 uint64 byte char].freeze
60
+
61
+ RUBY_RESERVED = %w[
62
+ BEGIN END alias and begin break case class def defined? do else elsif end
63
+ ensure false for if in module next nil not or redo rescue retry return
64
+ self super then true undef unless until when while yield __FILE__ __LINE__
65
+ __ENCODING__ hash class object_id send method initialize
66
+ ].freeze
67
+
68
+ class Error < StandardError; end
69
+
70
+ # One field: base is the .msg type word ("float64", "std_msgs/msg/Header"),
71
+ # nested is the full name of a message type (nil for primitives).
72
+ Field = Struct.new(:name, :base, :nested, :array, :size, :string_max, :default, keyword_init: true) do
73
+ def primitive?
74
+ nested.nil?
75
+ end
76
+
77
+ def string?
78
+ base == "string" || base == "wstring"
79
+ end
80
+ end
81
+
82
+ Constant = Struct.new(:name, :base, :value, keyword_init: true)
83
+
84
+ # A message (also a service's Request / Response / Event).
85
+ Message = Struct.new(:full_name, :pkg, :name, :fields, :constants, :source, :service, keyword_init: true)
86
+
87
+ Service = Struct.new(:full_name, :pkg, :name, :request, :response, :event, :source, keyword_init: true)
88
+
89
+ # "pkg/msg/Name" or "pkg/srv/Name" -> [pkg, kind, name]
90
+ def self.split_name(full)
91
+ parts = full.split("/")
92
+ raise Error, "bad type name #{full.inspect} (want pkg/msg/Name)" unless parts.size == 3
93
+ parts
94
+ end
95
+
96
+ def self.camel(pkg)
97
+ pkg.split("_").map { |w| w.empty? ? "" : w[0].upcase + w[1..] }.join
98
+ end
99
+
100
+ # ---- parsing -------------------------------------------------------------
101
+
102
+ class Parser
103
+ TYPE_RE = %r{\A([A-Za-z][A-Za-z0-9_]*(?:/[A-Za-z][A-Za-z0-9_]*)?)(?:<=(\d+))?(?:\[(<=)?(\d*)\])?\z}
104
+ FIELD_NAME_RE = /\A[a-z][a-z0-9_]*\z/
105
+ CONST_NAME_RE = /\A[A-Z][A-Z0-9_]*\z/
106
+
107
+ def initialize(pkg, source)
108
+ @pkg = pkg
109
+ @source = source
110
+ end
111
+
112
+ # Lines of a .msg body -> [fields, constants]
113
+ def parse_lines(lines)
114
+ fields = []
115
+ constants = []
116
+ lines.each_with_index do |raw, i|
117
+ line = strip_comment(raw).strip
118
+ next if line.empty?
119
+ begin
120
+ parse_line(line, fields, constants)
121
+ rescue Error => e
122
+ raise Error, "#{@source}:#{i + 1}: #{e.message}"
123
+ end
124
+ end
125
+ [fields, constants]
126
+ end
127
+
128
+ def strip_comment(line)
129
+ quote = nil
130
+ line.each_char.with_index do |c, i|
131
+ if quote
132
+ quote = nil if c == quote
133
+ elsif c == '"' || c == "'"
134
+ quote = c
135
+ elsif c == "#"
136
+ return line[0, i]
137
+ end
138
+ end
139
+ line
140
+ end
141
+
142
+ def parse_line(line, fields, constants)
143
+ type_word, rest = line.split(/\s+/, 2)
144
+ raise Error, "no name after #{type_word}" if rest.nil? || rest.empty?
145
+ if (m = rest.match(/\A([A-Za-z][A-Za-z0-9_]*)\s*=\s*(.*)\z/)) && !rest.match?(/\A[a-z][a-z0-9_]*\s+\S/)
146
+ constants << parse_constant(type_word, m[1], m[2].strip)
147
+ return
148
+ end
149
+ name, default = rest.split(/\s+/, 2)
150
+ raise Error, "bad field name #{name.inspect}" unless FIELD_NAME_RE.match?(name)
151
+ raise Error, "field name #{name.inspect} clashes with Ruby" if RUBY_RESERVED.include?(name)
152
+ f = parse_type(type_word)
153
+ f.name = name
154
+ f.default = default.nil? || default.empty? ? nil : parse_default(f, default.strip)
155
+ fields << f
156
+ end
157
+
158
+ def parse_type(word)
159
+ m = TYPE_RE.match(word)
160
+ raise Error, "bad type #{word.inspect}" unless m
161
+ base = m[1]
162
+ f = Field.new
163
+ if m[2]
164
+ raise Error, "only strings can be bounded: #{word}" unless %w[string wstring].include?(base)
165
+ f.string_max = m[2].to_i
166
+ end
167
+ if word.include?("[")
168
+ if m[3]
169
+ f.array = :bounded
170
+ f.size = m[4].to_i
171
+ raise Error, "bounded sequence without a bound: #{word}" if m[4].empty?
172
+ elsif m[4].empty?
173
+ f.array = :unbounded
174
+ else
175
+ f.array = :fixed
176
+ f.size = m[4].to_i
177
+ end
178
+ end
179
+ if PRIMITIVES.key?(base)
180
+ f.base = base
181
+ else
182
+ f.nested = resolve(base)
183
+ f.base = f.nested
184
+ end
185
+ f
186
+ end
187
+
188
+ # A nested type word of a .msg -> its full name.
189
+ def resolve(base)
190
+ if base.include?("/")
191
+ pkg, name = base.split("/")
192
+ "#{pkg}/msg/#{name}"
193
+ elsif base == "Header"
194
+ "std_msgs/msg/Header"
195
+ else
196
+ "#{@pkg}/msg/#{base}"
197
+ end
198
+ end
199
+
200
+ def parse_constant(type_word, name, value)
201
+ raise Error, "bad constant name #{name.inspect}" unless CONST_NAME_RE.match?(name)
202
+ raise Error, "constants must be primitive: #{type_word}" unless PRIMITIVES.key?(type_word)
203
+ v = type_word == "string" || type_word == "wstring" ? value : scalar(type_word, value)
204
+ Constant.new(name: name, base: type_word, value: v)
205
+ end
206
+
207
+ def parse_default(f, text)
208
+ raise Error, "nested fields have no default" unless f.primitive?
209
+ if f.array
210
+ raise Error, "array default must be [..]: #{text}" unless text.start_with?("[") && text.end_with?("]")
211
+ items = split_list(text[1..-2])
212
+ items.map { |s| f.string? ? unquote(s) : scalar(f.base, s) }
213
+ elsif f.string?
214
+ unquote(text)
215
+ else
216
+ scalar(f.base, text)
217
+ end
218
+ end
219
+
220
+ def split_list(body)
221
+ out = []
222
+ cur = +""
223
+ quote = nil
224
+ body.each_char do |c|
225
+ if quote
226
+ cur << c
227
+ quote = nil if c == quote
228
+ elsif c == '"' || c == "'"
229
+ quote = c
230
+ cur << c
231
+ elsif c == ","
232
+ out << cur.strip
233
+ cur = +""
234
+ else
235
+ cur << c
236
+ end
237
+ end
238
+ out << cur.strip unless cur.strip.empty?
239
+ out
240
+ end
241
+
242
+ def unquote(s)
243
+ if s.size >= 2 && (s[0] == '"' || s[0] == "'") && s[-1] == s[0]
244
+ s[1..-2].gsub("\\#{s[0]}", s[0])
245
+ else
246
+ s
247
+ end
248
+ end
249
+
250
+ def scalar(base, s)
251
+ case base
252
+ when "bool"
253
+ return true if %w[true True 1].include?(s)
254
+ return false if %w[false False 0].include?(s)
255
+ raise Error, "bad bool #{s.inspect}"
256
+ when *FLOAT_KINDS
257
+ Float(s)
258
+ else
259
+ Integer(s)
260
+ end
261
+ rescue ArgumentError
262
+ raise Error, "bad #{base} value #{s.inspect}"
263
+ end
264
+ end
265
+
266
+ # ---- the set of known types --------------------------------------------
267
+
268
+ class Registry
269
+ attr_reader :include_dirs
270
+
271
+ def initialize(include_dirs)
272
+ @include_dirs = include_dirs
273
+ @messages = {}
274
+ @services = {}
275
+ end
276
+
277
+ # The .msg / .srv files read so far.
278
+ def sources
279
+ (@messages.values + @services.values).map(&:source).uniq.sort
280
+ end
281
+
282
+ # A message by full name ("pkg/msg/Name", or a service part such as
283
+ # "pkg/srv/Name_Request"), parsed on first use.
284
+ def message(full)
285
+ return @messages[full] if @messages.key?(full)
286
+ pkg, kind, name = AsterismMsgGen.split_name(full)
287
+ if kind == "srv"
288
+ base = name.sub(/_(Request|Response|Event)\z/, "")
289
+ raise Error, "unknown service part #{full}" if base == name
290
+ service("#{pkg}/srv/#{base}")
291
+ return @messages.fetch(full)
292
+ end
293
+ raise Error, "not a message: #{full}" unless kind == "msg"
294
+ path = find("#{pkg}/msg/#{name}.msg")
295
+ load_msg_file(path, pkg, name)
296
+ end
297
+
298
+ def service(full)
299
+ return @services[full] if @services.key?(full)
300
+ pkg, kind, name = AsterismMsgGen.split_name(full)
301
+ raise Error, "not a service: #{full}" unless kind == "srv"
302
+ path = find("#{pkg}/srv/#{name}.srv")
303
+ load_srv_file(path, pkg, name)
304
+ end
305
+
306
+ def type(full)
307
+ _, kind, = AsterismMsgGen.split_name(full)
308
+ kind == "srv" && !full.match?(/_(Request|Response|Event)\z/) ? service(full) : message(full)
309
+ end
310
+
311
+ # The license a package declares in its package.xml (ROS layout:
312
+ # <dir>/<pkg>/package.xml), or nil when there is none. Several
313
+ # <license> tags are joined with ", ".
314
+ def license_of(pkg)
315
+ @licenses ||= {}
316
+ return @licenses[pkg] if @licenses.key?(pkg)
317
+ @licenses[pkg] = nil
318
+ @include_dirs.each do |d|
319
+ xml = File.join(d, pkg, "package.xml")
320
+ next unless File.file?(xml)
321
+ names = File.read(xml).scan(%r{<license(?:\s[^>]*)?>([^<]+)</license>}).map { |m| m[0].strip }
322
+ @licenses[pkg] = names.join(", ") unless names.empty?
323
+ break
324
+ end
325
+ @licenses[pkg]
326
+ end
327
+
328
+ def find(rel)
329
+ @include_dirs.each do |d|
330
+ p = File.join(d, rel)
331
+ return p if File.file?(p)
332
+ end
333
+ raise Error, "#{rel} not found under #{@include_dirs.join(', ')}"
334
+ end
335
+
336
+ # A .msg / .srv path -> full name. The package is the directory above
337
+ # msg/ or srv/ (the ROS layout).
338
+ def name_of_file(path)
339
+ dir = File.basename(File.dirname(path))
340
+ pkg = File.basename(File.dirname(File.dirname(path)))
341
+ ext = File.extname(path)
342
+ kind = ext == ".srv" ? "srv" : "msg"
343
+ raise Error, "#{path}: expected <pkg>/#{kind}/<Name>#{ext}" unless dir == kind
344
+ full = "#{pkg}/#{kind}/#{File.basename(path, ext)}"
345
+ if kind == "srv"
346
+ @services[full] ||= load_srv_file(path, pkg, File.basename(path, ext))
347
+ else
348
+ @messages[full] ||= load_msg_file(path, pkg, File.basename(path, ext))
349
+ end
350
+ full
351
+ end
352
+
353
+ def load_msg_file(path, pkg, name)
354
+ full = "#{pkg}/msg/#{name}"
355
+ fields, constants = Parser.new(pkg, path).parse_lines(File.read(path).lines)
356
+ @messages[full] = Message.new(full_name: full, pkg: pkg, name: name, fields: fields,
357
+ constants: constants, source: path)
358
+ end
359
+
360
+ def load_srv_file(path, pkg, name)
361
+ full = "#{pkg}/srv/#{name}"
362
+ lines = File.read(path).lines
363
+ sep = lines.index { |l| l.strip == "---" }
364
+ raise Error, "#{path}: no --- line" unless sep
365
+ parser = Parser.new(pkg, path)
366
+ parts = {}
367
+ [["Request", lines[0...sep]], ["Response", lines[(sep + 1)..]]].each do |suffix, body|
368
+ fields, constants = parser.parse_lines(body)
369
+ mfull = "#{full}_#{suffix}"
370
+ parts[suffix] = @messages[mfull] = Message.new(full_name: mfull, pkg: pkg, name: "#{name}_#{suffix}",
371
+ fields: fields, constants: constants, source: path,
372
+ service: full)
373
+ end
374
+ # The event message every service has (REP 2011 / service introspection).
375
+ event_full = "#{full}_Event"
376
+ event_fields = [
377
+ Field.new(name: "info", base: "service_msgs/msg/ServiceEventInfo", nested: "service_msgs/msg/ServiceEventInfo"),
378
+ Field.new(name: "request", base: parts["Request"].full_name, nested: parts["Request"].full_name, array: :bounded, size: 1),
379
+ Field.new(name: "response", base: parts["Response"].full_name, nested: parts["Response"].full_name, array: :bounded, size: 1)
380
+ ]
381
+ @messages[event_full] = Message.new(full_name: event_full, pkg: pkg, name: "#{name}_Event",
382
+ fields: event_fields, constants: [], source: path, service: full)
383
+ @services[full] = Service.new(full_name: full, pkg: pkg, name: name, request: parts["Request"],
384
+ response: parts["Response"], event: @messages[event_full], source: path)
385
+ end
386
+ end
387
+
388
+ # ---- type hashes ------------------------------------------------------
389
+
390
+ class Hasher
391
+ def initialize(registry)
392
+ @reg = registry
393
+ end
394
+
395
+ def field_type(f)
396
+ id = if f.nested
397
+ NESTED_ID
398
+ elsif f.string_max
399
+ f.base == "string" ? BOUNDED_STRING_ID : BOUNDED_WSTRING_ID
400
+ else
401
+ PRIMITIVES.fetch(f.base)[0]
402
+ end
403
+ id += ARRAY_OFFSET[f.array] if f.array
404
+ {
405
+ "type_id" => id,
406
+ "capacity" => f.array == :fixed || f.array == :bounded ? f.size : 0,
407
+ "string_capacity" => f.string_max || 0,
408
+ "nested_type_name" => f.nested || ""
409
+ }
410
+ end
411
+
412
+ # The individual description of a type (no references), without defaults.
413
+ def individual(full)
414
+ t = @reg.type(full)
415
+ fields = if t.is_a?(Service)
416
+ [["request_message", t.request.full_name], ["response_message", t.response.full_name],
417
+ ["event_message", t.event.full_name]].map do |n, nested|
418
+ { "name" => n, "type" => field_type(Field.new(base: nested, nested: nested)) }
419
+ end
420
+ else
421
+ fs = t.fields
422
+ if fs.empty?
423
+ # rosidl gives an empty structure one placeholder member.
424
+ fs = [Field.new(name: "structure_needs_at_least_one_member", base: "uint8")]
425
+ end
426
+ fs.map { |f| { "name" => f.name, "type" => field_type(f) } }
427
+ end
428
+ { "type_name" => full, "fields" => fields }
429
+ end
430
+
431
+ def full_description(full)
432
+ top = individual(full)
433
+ refs = {}
434
+ queue = top["fields"].map { |f| f["type"]["nested_type_name"] }.reject(&:empty?)
435
+ until queue.empty?
436
+ n = queue.pop
437
+ next if refs.key?(n)
438
+ refs[n] = individual(n)
439
+ queue.concat(refs[n]["fields"].map { |f| f["type"]["nested_type_name"] }.reject(&:empty?))
440
+ end
441
+ { "type_description" => top, "referenced_type_descriptions" => refs.keys.sort.map { |k| refs[k] } }
442
+ end
443
+
444
+ def type_hash(full)
445
+ "RIHS01_" + Digest::SHA256.hexdigest(AsterismMsgGen.py_json(full_description(full)))
446
+ end
447
+ end
448
+
449
+ # Python's json.dumps(obj, ensure_ascii=True, separators=(", ", ": ")).
450
+ def self.py_json(o)
451
+ case o
452
+ when Hash then "{" + o.map { |k, v| "#{py_str(k.to_s)}: #{py_json(v)}" }.join(", ") + "}"
453
+ when Array then "[" + o.map { |v| py_json(v) }.join(", ") + "]"
454
+ when String then py_str(o)
455
+ when Integer then o.to_s
456
+ when true then "true"
457
+ when false then "false"
458
+ when nil then "null"
459
+ else raise Error, "cannot dump #{o.class}"
460
+ end
461
+ end
462
+
463
+ def self.py_str(s)
464
+ out = +'"'
465
+ s.each_char do |c|
466
+ out << case c
467
+ when '"' then '\\"'
468
+ when "\\" then "\\\\"
469
+ when "\n" then "\\n"
470
+ when "\r" then "\\r"
471
+ when "\t" then "\\t"
472
+ when "\b" then "\\b"
473
+ when "\f" then "\\f"
474
+ else
475
+ cp = c.ord
476
+ if cp < 0x20 || cp > 0x7e
477
+ if cp > 0xffff
478
+ cp -= 0x10000
479
+ format("\\u%04x\\u%04x", 0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff))
480
+ else
481
+ format("\\u%04x", cp)
482
+ end
483
+ else
484
+ c
485
+ end
486
+ end
487
+ end
488
+ out << '"'
489
+ end
490
+
491
+ # ---- Ruby code --------------------------------------------------------
492
+
493
+ class Emitter
494
+ def initialize(registry, hasher)
495
+ @reg = registry
496
+ @hasher = hasher
497
+ end
498
+
499
+ def self.rel_path(full)
500
+ "#{full}.rb"
501
+ end
502
+
503
+ # The Ruby constant path of a type, e.g. ::Asterism::ROS::GeometryMsgs::Twist
504
+ def const_of(full)
505
+ pkg, kind, name = AsterismMsgGen.split_name(full)
506
+ if kind == "srv" && (m = name.match(/\A(.+)_(Request|Response|Event)\z/))
507
+ "::Asterism::ROS::#{AsterismMsgGen.camel(pkg)}::#{m[1]}::#{m[2]}"
508
+ else
509
+ "::Asterism::ROS::#{AsterismMsgGen.camel(pkg)}::#{name}"
510
+ end
511
+ end
512
+
513
+ # The types a file must load first (the messages its fields are made of,
514
+ # other than the ones it defines itself).
515
+ def runtime_deps(t)
516
+ msgs = t.is_a?(Service) ? [t.request, t.response] : [t]
517
+ own = msgs.map(&:full_name)
518
+ msgs.flat_map { |m| m.fields.map(&:nested).compact }.uniq.reject { |n| own.include?(n) }.sort
519
+ end
520
+
521
+ def file(full)
522
+ t = @reg.type(full)
523
+ out = +""
524
+ out << "# #{full} (ROS 2 #{ROS_DISTRO}), generated by asterism_msggen.rb from\n"
525
+ out << "# #{File.basename(t.source)} of the package #{t.pkg}.\n"
526
+ # Where the definition came from and under which terms: the license
527
+ # its package.xml declares, which this file (made from it) shares.
528
+ lic = @reg.license_of(t.pkg)
529
+ out << "# License: #{lic}, as the definition (package.xml of #{t.pkg}).\n" if lic
530
+ out << "# Do not edit; regenerate instead.\n"
531
+ deps = runtime_deps(t)
532
+ unless deps.empty?
533
+ out << "\n"
534
+ deps.each { |d| out << "::Asterism::ROS.require_type(#{d.inspect})\n" }
535
+ end
536
+ mod = "::Asterism::ROS::#{AsterismMsgGen.camel(t.pkg)}"
537
+ # Full names, no nesting: on mruby each module body is more compiled
538
+ # code and a deeper compile (report/r3.md). "::" because the file is
539
+ # evaluated inside a method there (Asterism::ROS.require_type).
540
+ out << "\nmodule #{mod}\nend\n\n"
541
+ out << if t.is_a?(Service)
542
+ service_body(t, mod)
543
+ else
544
+ message_body(t, "#{mod}::#{t.name}", @hasher.type_hash(full))
545
+ end
546
+ out
547
+ end
548
+
549
+ def service_body(s, mod)
550
+ dds = "#{s.pkg}::srv::dds_::#{s.name}_"
551
+ path = "#{mod}::#{s.name}"
552
+ out = +""
553
+ out << "# #{s.full_name}: a service. Request and Response are its messages;\n"
554
+ out << "# TYPE_NAME and TYPE_HASH are the service's (what rmw_zenoh puts in keys).\n"
555
+ out << "module #{path}\n"
556
+ out << " ROS_NAME = #{s.full_name.inspect}\n"
557
+ out << " TYPE_NAME = #{dds.inspect}\n"
558
+ out << " TYPE_HASH = #{@hasher.type_hash(s.full_name).inspect}\n"
559
+ out << "end\n"
560
+ [s.request, s.response].each do |m|
561
+ out << "\n"
562
+ suffix = m.name.sub("#{s.name}_", "")
563
+ out << message_body(m, "#{path}::#{suffix}", @hasher.type_hash(m.full_name))
564
+ end
565
+ out
566
+ end
567
+
568
+ def dds_name(m)
569
+ _, kind, = AsterismMsgGen.split_name(m.full_name)
570
+ "#{m.pkg}::#{kind}::dds_::#{m.name}_"
571
+ end
572
+
573
+ def message_body(m, class_path, hash)
574
+ fs = m.fields
575
+ out = +""
576
+ out << "class #{class_path} < ::Asterism::ROS::Message\n"
577
+ out << " ROS_NAME = #{m.full_name.inspect}\n"
578
+ out << " TYPE_NAME = #{dds_name(m).inspect}\n"
579
+ out << " TYPE_HASH = #{hash.inspect}\n"
580
+ out << " FIELDS = [#{fs.map { |f| ":#{f.name}" }.join(', ')}]\n"
581
+ m.constants.each do |c|
582
+ out << " #{c.name} = #{lit(c.value, c.base)}\n"
583
+ end
584
+ out << "\n"
585
+ out << " attr_accessor #{fs.map { |f| ":#{f.name}" }.join(', ')}\n" unless fs.empty?
586
+ out << "\n" unless fs.empty?
587
+ # initialize
588
+ if fs.empty?
589
+ out << " def initialize\n end\n"
590
+ else
591
+ out << " def initialize(#{fs.map { |f| "#{f.name}: nil" }.join(', ')})\n"
592
+ fs.each { |f| out << " @#{f.name} = #{init_expr(f)}\n" }
593
+ out << " end\n"
594
+ end
595
+ out << "\n"
596
+ out << " def self.write(w, m)\n"
597
+ if fs.empty?
598
+ out << " w.uint8(0)\n"
599
+ else
600
+ fs.each { |f| out << " #{write_stmt(f)}\n" }
601
+ end
602
+ out << " end\n\n"
603
+ out << " def self.read(r)\n"
604
+ if fs.empty?
605
+ out << " r.uint8\n new\n"
606
+ else
607
+ out << " m = new\n"
608
+ fs.each { |f| out << " m.#{f.name} = #{read_expr(f)}\n" }
609
+ out << " m\n"
610
+ end
611
+ out << " end\n"
612
+ out << "end\n"
613
+ end
614
+
615
+ def kind(f)
616
+ PRIMITIVES.fetch(f.base)[1]
617
+ end
618
+
619
+ def byte_kind?(f)
620
+ BYTE_KINDS.include?(f.base)
621
+ end
622
+
623
+ def lit(v, base)
624
+ case v
625
+ when Array then "[" + v.map { |x| lit(x, base) }.join(", ") + "]"
626
+ when String then v.inspect
627
+ when Float
628
+ if v.nan? then "::Float::NAN"
629
+ elsif v.infinite? then v > 0 ? "::Float::INFINITY" : "-::Float::INFINITY"
630
+ else v.to_s
631
+ end
632
+ when Integer
633
+ FLOAT_KINDS.include?(base) ? v.to_f.to_s : v.to_s
634
+ else v.inspect
635
+ end
636
+ end
637
+
638
+ def zero(f)
639
+ case f.base
640
+ when "bool" then "false"
641
+ when "string", "wstring" then '""'
642
+ when *FLOAT_KINDS then "0.0"
643
+ else "0"
644
+ end
645
+ end
646
+
647
+ # The value a field starts with when it is not given.
648
+ def default_expr(f)
649
+ if f.nested
650
+ c = const_of(f.nested)
651
+ case f.array
652
+ when nil then "#{c}.new"
653
+ when :fixed then "::Array.new(#{f.size}) { #{c}.new }"
654
+ else "[]"
655
+ end
656
+ elsif f.array
657
+ if f.default
658
+ if byte_kind?(f)
659
+ "::Asterism::CDR.byte_string(#{lit(f.default, f.base)})"
660
+ else
661
+ lit(f.default, f.base)
662
+ end
663
+ elsif byte_kind?(f)
664
+ f.array == :fixed ? "\"\\x00\" * #{f.size}" : '""'
665
+ elsif f.array == :fixed
666
+ "::Array.new(#{f.size}, #{zero(f)})"
667
+ else
668
+ "[]"
669
+ end
670
+ else
671
+ f.default.nil? ? zero(f) : lit(f.default, f.base)
672
+ end
673
+ end
674
+
675
+ def init_expr(f)
676
+ d = default_expr(f)
677
+ if f.nested && f.array.nil?
678
+ "#{const_of(f.nested)}.from(#{f.name})"
679
+ elsif f.nested
680
+ "#{f.name}.nil? ? #{d} : #{f.name}.map { |e| #{const_of(f.nested)}.from(e) }"
681
+ else
682
+ "#{f.name}.nil? ? #{d} : #{f.name}"
683
+ end
684
+ end
685
+
686
+ def bound_args(f)
687
+ case f.array
688
+ when :fixed then "#{f.size}, nil"
689
+ when :bounded then "nil, #{f.size}"
690
+ else "nil, nil"
691
+ end
692
+ end
693
+
694
+ def write_stmt(f)
695
+ v = "m.#{f.name}"
696
+ if f.nested
697
+ c = const_of(f.nested)
698
+ return "#{c}.write(w, #{c}.from(#{v}))" if f.array.nil?
699
+ return "w.structs(#{c}, #{v}, #{bound_args(f)})"
700
+ end
701
+ if f.array
702
+ return "w.bytes(#{v}, #{bound_args(f)})" if byte_kind?(f)
703
+ return "w.array(:#{kind(f)}, #{v}, #{bound_args(f)}, #{f.string_max.inspect})" if f.string?
704
+ return "w.array(:#{kind(f)}, #{v}, #{bound_args(f)})"
705
+ end
706
+ return "w.#{kind(f)}(#{v}, #{f.string_max})" if f.string? && f.string_max
707
+ "w.#{kind(f)}(#{v})"
708
+ end
709
+
710
+ def read_expr(f)
711
+ if f.nested
712
+ c = const_of(f.nested)
713
+ return "#{c}.read(r)" if f.array.nil?
714
+ return "r.structs(#{c}, #{f.array == :fixed ? f.size : 'nil'})"
715
+ end
716
+ fixed = f.array == :fixed ? f.size : "nil"
717
+ if f.array
718
+ return "r.bytes(#{fixed})" if byte_kind?(f)
719
+ return "r.array(:#{kind(f)}, #{fixed})"
720
+ end
721
+ "r.#{kind(f)}"
722
+ end
723
+ end
724
+
725
+ # ---- driver -----------------------------------------------------------
726
+
727
+ # Every type full name a set of requested types needs in the output
728
+ # (the requested ones and the messages their fields use).
729
+ def self.closure(registry, names)
730
+ out = []
731
+ queue = names.dup
732
+ until queue.empty?
733
+ n = queue.shift
734
+ next if out.include?(n)
735
+ out << n
736
+ t = registry.type(n)
737
+ msgs = t.is_a?(Service) ? [t.request, t.response] : [t]
738
+ msgs.each { |m| m.fields.each { |f| queue << f.nested if f.nested } }
739
+ end
740
+ out
741
+ end
742
+
743
+ # All the hash names of a type: itself and what it references, as in the
744
+ # type_hashes list of the ROS 2 type description JSON.
745
+ def self.hash_names(hasher, full)
746
+ d = hasher.full_description(full)
747
+ [full] + d["referenced_type_descriptions"].map { |r| r["type_name"] }
748
+ end
749
+
750
+ # Compares the hashes with <dir>/<pkg>/<msg|srv>/<Name>.json. Returns
751
+ # [checked, mismatches, missing].
752
+ def self.check_json(hasher, names, dirs)
753
+ checked = 0
754
+ bad = []
755
+ missing = []
756
+ names.each do |full|
757
+ pkg, kind, name = split_name(full)
758
+ path = dirs.map { |d| File.join(d, pkg, kind, "#{name}.json") }.find { |p| File.file?(p) }
759
+ if path.nil?
760
+ missing << full
761
+ next
762
+ end
763
+ JSON.parse(File.read(path))["type_hashes"].each do |th|
764
+ mine = hasher.type_hash(th["type_name"])
765
+ checked += 1
766
+ bad << [th["type_name"], mine, th["hash_string"]] unless mine == th["hash_string"]
767
+ end
768
+ end
769
+ [checked, bad, missing]
770
+ end
771
+
772
+ def self.main(argv)
773
+ include_dirs = []
774
+ out_dir = nil
775
+ json_dirs = []
776
+ mode = :generate
777
+ deps = true
778
+ op = OptionParser.new do |o|
779
+ o.banner = "usage: asterism_msggen.rb [-I DIR]... (-o OUT | --hash | --check-json DIR) TYPE_OR_FILE..."
780
+ o.on("-I DIR", "where <pkg>/msg/*.msg and <pkg>/srv/*.srv are (repeatable)") { |d| include_dirs << d }
781
+ o.on("-o OUT", "write <OUT>/<pkg>/<msg|srv>/<Name>.rb") { |d| out_dir = d }
782
+ o.on("--no-deps", "write only the named types, not the types they use") { deps = false }
783
+ o.on("--hash", "print the RIHS01 hashes instead of writing files") { mode = :hash }
784
+ o.on("--check-json DIR", "compare the hashes with the ROS 2 type description JSON under DIR") do |d|
785
+ mode = :check
786
+ json_dirs << d
787
+ end
788
+ end
789
+ args = op.parse(argv)
790
+ abort op.banner if args.empty?
791
+ reg = Registry.new(include_dirs)
792
+ names = args.map do |a|
793
+ if a.end_with?(".msg") || a.end_with?(".srv")
794
+ reg.include_dirs << File.dirname(File.dirname(File.dirname(File.expand_path(a))))
795
+ reg.name_of_file(a)
796
+ else
797
+ a
798
+ end
799
+ end
800
+ hasher = Hasher.new(reg)
801
+ case mode
802
+ when :hash
803
+ names.each { |n| hash_names(hasher, n).each { |h| puts "#{h} #{hasher.type_hash(h)}" } }
804
+ when :check
805
+ all = closure(reg, names)
806
+ checked, bad, missing = check_json(hasher, all, json_dirs)
807
+ bad.each { |n, mine, theirs| warn "MISMATCH #{n}: computed #{mine}, json #{theirs}" }
808
+ missing.each { |n| warn "no JSON for #{n}" }
809
+ puts "type hashes: #{checked} checked, #{bad.size} mismatched, #{missing.size} without JSON"
810
+ exit(bad.empty? && missing.empty? ? 0 : 1)
811
+ else
812
+ abort "-o OUT is needed" unless out_dir
813
+ em = Emitter.new(reg, hasher)
814
+ (deps ? closure(reg, names) : names).each do |n|
815
+ path = File.join(out_dir, Emitter.rel_path(n))
816
+ FileUtils.mkdir_p(File.dirname(path))
817
+ File.write(path, em.file(n))
818
+ puts path
819
+ end
820
+ end
821
+ rescue Error => e
822
+ abort "asterism_msggen: #{e.message}"
823
+ end
824
+ end
825
+
826
+ AsterismMsgGen.main(ARGV) if $PROGRAM_NAME == __FILE__