automotive-ecu 0.4.1 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e5446929b1a5bedccd68a5e669c148a0386663feda51949c8ebaee2fe143db3
4
- data.tar.gz: ede842831f425f87c3092be6cd72185b82f5eb8b2a7d50ccc635ba23ec85b661
3
+ metadata.gz: cbc3b625690b9f5d8d6bf67e86b3ce6134cd35ae10240f1b02a2708c2a63fd8f
4
+ data.tar.gz: 337197db174176a05b20c762edc9ed31f7f9cdaa5ec9ccc7d4a4e28c2a70e491
5
5
  SHA512:
6
- metadata.gz: 4b408ab9515e6909db8d4f9efa029ffd9cfa498afb9f5d5ecbff14a6283338741b6d8cc931db4b2b5eec52096ecb77b20e8072890d7e229b8b0f1fb1f176e02a
7
- data.tar.gz: 7a9bed9c8d5568bccd4423f0578c28062e8e769b4a0c5bc44770d2356307bc1f8e33dcbebac0d430e48a6f8578021032d2c79c7072c42194828b269e4a52b445
6
+ metadata.gz: 790eaa6e0abc424b844153076789a6847a35806af182ee502fc7105d63de02b0778128f70ea6b774a235eff166b9480624aead5919122706389f0f0f5f620003
7
+ data.tar.gz: 2712b9924613bddac8486f4dfeb69b0a7ff52172067e8ab0c198cd6f6bdad4f65a025f4734f543e45020e8fe74871fb52355ee6ac8820df2395025f3eba20bd4
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "a2l_parser_error"
4
+
5
+ class Ecu
6
+ class LabelList
7
+ class A2lConversion
8
+ def initialize(blocks)
9
+ @methods = {}
10
+ @tables = {}
11
+ @units = {}
12
+ index(blocks)
13
+ end
14
+
15
+ def convert(name, value)
16
+ return value if name == "NO_COMPU_METHOD"
17
+
18
+ method = lookup(@methods, name)
19
+ case method.values[2]
20
+ when "IDENTICAL"
21
+ value
22
+ when "LINEAR"
23
+ slope, offset = coefficients(method, "COEFFS_LINEAR", 2)
24
+ number(number(value) * slope + offset)
25
+ when "RAT_FUNC"
26
+ rational(method, number(value))
27
+ when "TAB_VERB", "TAB_INTP", "TAB_NOINTP"
28
+ table_value(method, number(value))
29
+ else
30
+ fail_conversion("Unsupported conversion #{method.values[2]}: #{name}")
31
+ end
32
+ end
33
+
34
+ def unit(name)
35
+ return nil if name == "NO_COMPU_METHOD"
36
+
37
+ method = lookup(@methods, name)
38
+ reference = option(method, "REF_UNIT")
39
+ return method.values[4] unless reference
40
+
41
+ lookup(@units, reference.first).values[2]
42
+ end
43
+
44
+ private
45
+
46
+ def fail_conversion(message)
47
+ raise Ecu::A2lParserError, message
48
+ end
49
+
50
+ def index(blocks)
51
+ blocks.each do |block|
52
+ registry = case block.kind
53
+ when "COMPU_METHOD" then @methods
54
+ when "COMPU_TAB", "COMPU_VTAB", "COMPU_VTAB_RANGE" then @tables
55
+ when "UNIT" then @units
56
+ end
57
+ if registry
58
+ name = block.values.first
59
+ fail_conversion("Duplicate #{block.kind} #{name}") if registry.key?(name)
60
+ registry[name] = block
61
+ else
62
+ index(block.children)
63
+ end
64
+ end
65
+ end
66
+
67
+ def lookup(registry, name)
68
+ registry.fetch(name) { fail_conversion("Missing reference #{name}") }
69
+ end
70
+
71
+ def option(block, name)
72
+ child = block.children.find { |entry| entry.kind == name }
73
+ return child.values if child
74
+
75
+ # Header fields can contain arbitrary quoted text, including keywords.
76
+ start = case block.kind
77
+ when "COMPU_METHOD" then 5
78
+ when "COMPU_VTAB_RANGE" then 3 + table_count(block, 2) * 3
79
+ when "COMPU_TAB", "COMPU_VTAB" then 4 + table_count(block, 3) * 2
80
+ else 0
81
+ end
82
+ position = (start...block.values.length).find { |i| block.values[i] == name }
83
+ block.values[(position + 1)..-1] if position
84
+ end
85
+
86
+ def number(value)
87
+ return value if value.is_a?(Integer)
88
+ return Integer(value, 10) if value.is_a?(String) && /\A[+-]?[0-9]+\z/.match?(value)
89
+ return Integer(value, 16) if value.is_a?(String) && /\A[+-]?0[xX][0-9A-Fa-f]+\z/.match?(value)
90
+
91
+ result = Float(value)
92
+ fail_conversion("Non-finite numeric value #{value}") unless result.finite?
93
+ result
94
+ rescue ArgumentError, TypeError
95
+ fail_conversion("Invalid numeric value #{value.inspect}")
96
+ end
97
+
98
+ def coefficients(block, keyword, count)
99
+ values = option(block, keyword)
100
+ fail_conversion("Missing #{keyword} for #{block.values.first}") unless values && values.size >= count
101
+ values.first(count).map { |value| number(number(value).to_f) }
102
+ end
103
+
104
+ def rational(method, internal)
105
+ a, b, c, d, e, f = coefficients(method, "COEFFS", 6)
106
+ fail_conversion("Zero rational denominator") if [d, e, f].all?(&:zero?)
107
+ scale = normalization_scale([a, b, c, d, e, f])
108
+ a, b, c, d, e, f = [a, b, c, d, e, f].map { |coefficient| coefficient / scale }
109
+ # internal = (a*x*x + b*x + c) / (d*x*x + e*x + f)
110
+ internal = number(internal.to_f)
111
+ scale = normalization_scale([internal, 1.0])
112
+ aa, bb, cc = a / scale - internal / scale * d,
113
+ b / scale - internal / scale * e,
114
+ c / scale - internal / scale * f
115
+ scale = normalization_scale([aa, bb, cc])
116
+ aa, bb, cc = [aa, bb, cc].map { |coefficient| coefficient / scale }
117
+ if aa.zero?
118
+ fail_conversion(cc.zero? ? "ambiguous rational inverse" : "Rational domain error") if bb.zero?
119
+ roots = [-cc / bb]
120
+ else
121
+ discriminant = bb * bb - 4 * aa * cc
122
+ fail_conversion("Rational domain error") if discriminant < 0
123
+ if discriminant.zero?
124
+ roots = [-bb / (2 * aa)]
125
+ else
126
+ # Stable quadratic evaluation avoids subtracting nearly equal numbers.
127
+ q = -0.5 * (bb + (bb < 0 ? -Math.sqrt(discriminant) : Math.sqrt(discriminant)))
128
+ roots = [q / aa, cc / q]
129
+ end
130
+ end
131
+ roots = roots.uniq.select { |root| root.finite? && !(d * root * root + e * root + f).zero? }
132
+ fail_conversion("Zero rational denominator or domain error") if roots.empty?
133
+ fail_conversion("ambiguous rational inverse") if roots.length > 1
134
+ roots.first
135
+ end
136
+
137
+ def normalization_scale(values)
138
+ # Power-of-two scaling avoids rounding ordinary coefficients while keeping
139
+ # the discriminant away from overflow and underflow.
140
+ Math.ldexp(1.0, Math.frexp(values.map(&:abs).max).last - 1)
141
+ end
142
+
143
+ def table_count(table, position)
144
+ count = Integer(table.values[position], 10)
145
+ fail_conversion("Invalid table count") if count < 0
146
+ count
147
+ rescue ArgumentError, TypeError
148
+ fail_conversion("Invalid table count")
149
+ end
150
+
151
+ def table_value(method, value)
152
+ reference = option(method, "COMPU_TAB_REF")
153
+ fail_conversion("Missing COMPU_TAB_REF for #{method.values.first}") unless reference
154
+ table = lookup(@tables, reference.first)
155
+ verbal = method.values[2] == "TAB_VERB"
156
+ if table.kind == "COMPU_VTAB_RANGE" && verbal
157
+ count = table_count(table, 2)
158
+ rows = table.values.slice(3, count * 3)
159
+ fail_conversion("Malformed verbal ranges") unless rows && rows.length == count * 3
160
+ entries = rows.each_slice(3).map do |low, high, label|
161
+ low, high = number(low), number(high)
162
+ fail_conversion("Invalid verbal range") if low > high || label.nil?
163
+ [low, high, label]
164
+ end
165
+ matches = entries.select { |low, high, _| value >= low && value <= high }
166
+ fail_conversion("Ambiguous verbal range") if matches.size > 1
167
+ return matches.first[2] unless matches.empty?
168
+ else
169
+ expected = verbal ? "COMPU_VTAB" : "COMPU_TAB"
170
+ fail_conversion("Invalid table type #{table.kind}") unless table.kind == expected
171
+ count = table_count(table, 3)
172
+ rows = table.values.slice(4, count * 2)
173
+ fail_conversion("Malformed conversion table") unless rows && rows.length == count * 2
174
+ entries = rows.each_slice(2).map { |input, output| [number(input), verbal ? output : number(output)] }
175
+ fail_conversion("Duplicate table input") unless entries.map(&:first).uniq.size == entries.size
176
+ exact = entries.find { |input, _| input == value }
177
+ return exact[1] if exact
178
+ if method.values[2] == "TAB_INTP"
179
+ pair = entries.sort_by(&:first).each_cons(2).find { |left, right| value > left[0] && value < right[0] }
180
+ if pair
181
+ left, right = pair
182
+ # Subtract integer keys before converting, preserving adjacent 64-bit inputs.
183
+ distance, width = value - left[0], right[0] - left[0]
184
+ if distance.finite? && width.finite?
185
+ fraction = distance.fdiv(width)
186
+ else
187
+ scale = [value.abs, left[0].abs, right[0].abs].max
188
+ fraction = (value / scale - left[0] / scale) / (right[0] / scale - left[0] / scale)
189
+ end
190
+ return number(left[1] * (1.0 - fraction) + right[1] * fraction)
191
+ end
192
+ end
193
+ end
194
+ default = option(table, verbal ? "DEFAULT_VALUE" : "DEFAULT_VALUE_NUMERIC")
195
+ return verbal ? default.first : number(default.first) if default && !default.empty?
196
+
197
+ fail_conversion("No table entry or default for #{value}")
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,103 @@
1
+ require "strscan"
2
+ require_relative "a2l_parser_error"
3
+
4
+ class Ecu
5
+ class LabelList
6
+ class A2lDocument
7
+ Block = Struct.new(:kind, :values, :children, :line, keyword_init: true)
8
+
9
+ def initialize(text = nil, **keywords)
10
+ @text = keywords.fetch(:text, text)
11
+ end
12
+
13
+ def call
14
+ unless @text.is_a?(String) && @text.valid_encoding? && @text.encoding.ascii_compatible?
15
+ raise A2lParserError, "Invalid A2L text encoding at line 1"
16
+ end
17
+
18
+ @scanner = StringScanner.new(@text)
19
+ @line = 1
20
+ stack = []
21
+ modules = []
22
+ while (token = next_token)
23
+ value, line, quoted = token
24
+ if !quoted && value == "/begin"
25
+ kind = block_name(line)
26
+ ignored = (!stack.empty? && stack.last[2].nil?) ||
27
+ %w[MEASUREMENT A2ML IF_DATA].include?(kind)
28
+ block = ignored ? nil : Block.new(kind: kind, values: [], children: [], line: line)
29
+ stack.last[2].children << block if block && !stack.empty? && stack.last[2]
30
+ modules << block if block && kind == "MODULE"
31
+ stack << [kind, line, block]
32
+ elsif !quoted && value == "/end"
33
+ kind = block_name(line)
34
+ if stack.empty? || stack.last[0] != kind
35
+ expected = stack.empty? ? "no open block" : stack.last[0]
36
+ raise A2lParserError, "At line #{line}: /end #{kind}, expected #{expected}"
37
+ end
38
+ stack.pop
39
+ elsif !stack.empty? && stack.last[2]
40
+ stack.last[2].values << value
41
+ end
42
+ end
43
+ unless stack.empty?
44
+ kind, line = stack.last
45
+ raise A2lParserError, "Unclosed #{kind} block opened at line #{line}"
46
+ end
47
+ modules
48
+ ensure
49
+ @scanner = nil
50
+ end
51
+
52
+ private
53
+
54
+ def block_name(line)
55
+ token = next_token
56
+ unless token && !token[2] && token[0].match?(/\A[A-Za-z_][A-Za-z_0-9]*\z/)
57
+ raise A2lParserError, "Expected block name at line #{line}"
58
+ end
59
+ token[0]
60
+ end
61
+
62
+ def next_token
63
+ loop do
64
+ return if @scanner.eos?
65
+ if (space = @scanner.scan(/\s+/))
66
+ @line += space.count("\n")
67
+ elsif @scanner.scan(%r{//[^\n]*})
68
+ next
69
+ elsif @scanner.scan(%r{/\*})
70
+ line = @line
71
+ comment = @scanner.scan_until(%r{\*/})
72
+ raise A2lParserError, "Unclosed comment at line #{line}" unless comment
73
+ @line += comment.count("\n")
74
+ else
75
+ break
76
+ end
77
+ end
78
+
79
+ line = @line
80
+ if @scanner.peek(1) == '"'
81
+ text = @scanner.scan(/"(?:\\.|[^"\\])*"/m)
82
+ raise A2lParserError, "Unclosed string at line #{line}" unless text
83
+ @line += text.count("\n")
84
+ value = text[1...-1].gsub(/\\(.)/m) do
85
+ escaped = Regexp.last_match(1)
86
+ case escaped
87
+ when '"', "'", "\\" then escaped
88
+ when "n" then "\n"
89
+ when "r" then "\r"
90
+ when "t" then "\t"
91
+ else "\\#{escaped}"
92
+ end
93
+ end
94
+ [value, line, true]
95
+ else
96
+ text = @scanner.scan(/(?:(?!\/[\/\*])[^\s"])+/)
97
+ raise A2lParserError, "Invalid token at line #{line}" unless text
98
+ [text, line, false]
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "a2l_parser_error"
4
+ require_relative "../../labels/festwert"
5
+ require_relative "../../labels/festwerteblock"
6
+ require_relative "../../labels/kennlinie"
7
+ require_relative "../../labels/kennfeld"
8
+ require_relative "../../labels/stuetzstellenverteilung"
9
+
10
+ class Ecu
11
+ class LabelList
12
+ class A2lParser
13
+ def initialize(a2l, s19, address_map: nil)
14
+ @a2l, @s19, @address_map = a2l, s19, address_map
15
+ end
16
+
17
+ def call
18
+ require_relative "a2l_document" unless defined?(A2lDocument)
19
+ require_relative "s19_image" unless defined?(S19Image)
20
+ require_relative "a2l_conversion" unless defined?(A2lConversion)
21
+ @image = S19Image.new(@s19, address_map: @address_map)
22
+ A2lDocument.new(@a2l).call.flat_map { |mod| decode_module(mod) }
23
+ end
24
+
25
+ private
26
+
27
+ def decode_module(mod)
28
+ @layouts = index(mod.children, "RECORD_LAYOUT")
29
+ @shared = index(mod.children, "AXIS_PTS")
30
+ index(mod.children, "CHARACTERISTIC")
31
+ @common = mod.children.find { |b| b.kind == "MOD_COMMON" }
32
+ @conversion = A2lConversion.new(mod.children)
33
+ @axis_cache = {}
34
+ @functions = {}
35
+ mod.children.select { |b| b.kind == "FUNCTION" }.each do |function|
36
+ function.children.select { |b| %w[DEF_CHARACTERISTIC REF_CHARACTERISTIC].include?(b.kind) }.each do |refs|
37
+ refs.values.each { |name| @functions[name] ||= function.values.first }
38
+ end
39
+ end
40
+ mod.children.select { |b| %w[CHARACTERISTIC AXIS_PTS].include?(b.kind) }.map do |object|
41
+ begin
42
+ object.kind == "AXIS_PTS" ? shared_axis(object.values.first, []) : characteristic(object)
43
+ rescue Ecu::A2lParserError, ArgumentError, KeyError => e
44
+ raise Ecu::A2lParserError, "#{object.values.first} at #{object.values[object.kind == 'AXIS_PTS' ? 2 : 3]} (line #{object.line}): #{e.message}"
45
+ end
46
+ end
47
+ end
48
+
49
+ def index(blocks, kind)
50
+ blocks.select { |b| b.kind == kind }.each_with_object({}) do |b, result|
51
+ name = b.values.first
52
+ fail_format("Duplicate #{kind} #{name} (line #{b.line})") if result.key?(name)
53
+ result[name] = b
54
+ end
55
+ end
56
+
57
+ def fail_format(message)
58
+ raise Ecu::A2lParserError, message
59
+ end
60
+
61
+ def number(value)
62
+ fail_format("Missing number") if value.nil?
63
+ value.to_s.match?(/\A[+-]?0x/i) ? Integer(value, 16) : Integer(value, 10)
64
+ end
65
+
66
+ def numeric(value)
67
+ return number(value) if value.to_s.match?(/\A[+-]?(?:0x[0-9a-f]+|\d+)\z/i)
68
+ result = Float(value)
69
+ fail_format("Nonfinite fixed axis parameter") unless result.finite?
70
+ result
71
+ end
72
+
73
+ def options(block)
74
+ return [] unless block
75
+ offset = {"CHARACTERISTIC" => 9, "AXIS_PTS" => 10, "AXIS_DESCR" => 6, "MOD_COMMON" => 1}.fetch(block.kind, 0)
76
+ block.values.drop(offset)
77
+ end
78
+
79
+ def option(block, key, length = 1)
80
+ return nil unless block
81
+ child = block.children.find { |b| b.kind == key }
82
+ return child.values.first(length) if child
83
+ values = options(block)
84
+ i = values.index(key)
85
+ i ? values.slice(i + 1, length) : nil
86
+ end
87
+
88
+ def unit(block, conversion)
89
+ option(block, "PHYS_UNIT")&.first || @conversion.unit(conversion)
90
+ end
91
+
92
+ def metadata(block)
93
+ {name: block.values[0], description: block.values[1], function: @functions[block.values[0]]}
94
+ end
95
+
96
+ def byte_order(block)
97
+ order = option(block, "BYTE_ORDER")&.first || option(@common, "BYTE_ORDER")&.first || "MSB_LAST"
98
+ fail_format("Unsupported BYTE_ORDER #{order}") unless %w[MSB_LAST MSB_FIRST].include?(order)
99
+ order
100
+ end
101
+
102
+ def layout(block, name)
103
+ fail_format("Unsupported DEPOSIT RELATIVE") if option(block, "DEPOSIT")&.first == "RELATIVE"
104
+ definition = @layouts[name] || fail_format("Unknown RECORD_LAYOUT #{name}")
105
+ values = definition.values.drop(1)
106
+ fields, alignments = [], {}
107
+ until values.empty?
108
+ kind = values.shift
109
+ if kind.start_with?("ALIGNMENT_")
110
+ fail_format("Unsupported critical RECORD_LAYOUT field #{kind}") unless %w[
111
+ ALIGNMENT_BYTE ALIGNMENT_WORD ALIGNMENT_LONG ALIGNMENT_INT64
112
+ ALIGNMENT_FLOAT32_IEEE ALIGNMENT_FLOAT64_IEEE
113
+ ].include?(kind)
114
+ alignments[kind] = number(values.shift)
115
+ elsif kind == "FNC_VALUES" || %w[AXIS_PTS_X AXIS_PTS_Y].include?(kind)
116
+ position, type, direction, addressing = values.shift(4)
117
+ fail_format("Unsupported pointer addressing #{addressing}") unless addressing == "DIRECT"
118
+ allowed = kind == "FNC_VALUES" ? %w[ROW_DIR COLUMN_DIR] : %w[INDEX_INCR INDEX_DECR]
119
+ fail_format("Unsupported #{kind} direction #{direction}") unless allowed.include?(direction)
120
+ fields << [kind, number(position), type, direction]
121
+ elsif %w[NO_AXIS_PTS_X NO_AXIS_PTS_Y].include?(kind)
122
+ position, type = values.shift(2)
123
+ fields << [kind, number(position), type, nil]
124
+ else
125
+ fail_format("Unsupported critical RECORD_LAYOUT field #{kind}")
126
+ end
127
+ end
128
+ fail_format("Duplicate RECORD_LAYOUT component") unless fields.map(&:first).uniq.size == fields.size &&
129
+ fields.map { |f| f[1] }.uniq.size == fields.size
130
+ fail_format("Invalid RECORD_LAYOUT component position") unless fields.all? { |f| f[1].positive? }
131
+ fields.each do |kind, position, _type, _direction|
132
+ next unless kind.start_with?("NO_AXIS_PTS_")
133
+ axis_kind = kind.sub("NO_", "")
134
+ consumers = fields.select { |f| [axis_kind, "FNC_VALUES"].include?(f[0]) }
135
+ fail_format("Runtime axis count must precede dependent components") unless consumers.all? { |f| position < f[1] }
136
+ end
137
+ [fields.sort_by { |f| f[1] }, alignments]
138
+ end
139
+
140
+ def alignment(type, local)
141
+ key = case type
142
+ when "UBYTE", "SBYTE" then "ALIGNMENT_BYTE"
143
+ when "UWORD", "SWORD" then "ALIGNMENT_WORD"
144
+ when "ULONG", "SLONG" then "ALIGNMENT_LONG"
145
+ when "A_UINT64", "A_INT64" then "ALIGNMENT_INT64"
146
+ when "FLOAT32_IEEE" then "ALIGNMENT_FLOAT32_IEEE"
147
+ when "FLOAT64_IEEE" then "ALIGNMENT_FLOAT64_IEEE"
148
+ else fail_format("Unknown data type #{type}")
149
+ end
150
+ value = local[key] || number(option(@common, key)&.first || "1")
151
+ fail_format("Invalid #{key} #{value}") unless value.positive?
152
+ value
153
+ end
154
+
155
+ def deposit(block, name, address, dimensions, ascii: false)
156
+ fail_format("Invalid dimensions #{dimensions.inspect}") unless dimensions.all? { |d| d.is_a?(Integer) && d.positive? }
157
+ fields, local = layout(block, name)
158
+ data = {}
159
+ cursor = number(address)
160
+ fields.each do |kind, _position, type, direction|
161
+ size = S19Image::TYPES.fetch(type) { fail_format("Unknown data type #{type}") }.first
162
+ align = alignment(type, local)
163
+ cursor = (cursor + align - 1) / align * align
164
+ count = case kind
165
+ when "NO_AXIS_PTS_X", "NO_AXIS_PTS_Y" then 1
166
+ when "AXIS_PTS_X" then dimensions[0]
167
+ when "AXIS_PTS_Y" then dimensions[1]
168
+ else dimensions.reduce(:*)
169
+ end
170
+ fail_format("Invalid element count #{count}") unless count && count.positive?
171
+ data[kind] = if ascii && kind == "FNC_VALUES"
172
+ fail_format("ASCII requires byte storage") unless size == 1
173
+ @image.read(cursor, count)
174
+ else
175
+ @image.values(cursor, type, count, byte_order(block))
176
+ end
177
+ cursor += size * count
178
+ if kind.start_with?("NO_AXIS_PTS_")
179
+ i = kind.end_with?("X") ? 0 : 1
180
+ actual = data[kind].first
181
+ fail_format("Invalid runtime axis count #{actual}") unless actual.is_a?(Integer) && actual.positive? && actual <= dimensions[i]
182
+ dimensions[i] = actual
183
+ elsif kind.start_with?("AXIS_PTS_") && direction == "INDEX_DECR"
184
+ data[kind].reverse!
185
+ end
186
+ data["direction"] = direction if kind == "FNC_VALUES"
187
+ end
188
+ data
189
+ end
190
+
191
+ def converted(block, values, conversion)
192
+ mask = option(block, "BIT_MASK")&.first
193
+ if mask
194
+ mask = number(mask)
195
+ fail_format("Invalid BIT_MASK") unless mask.positive?
196
+ shift = 0
197
+ shift += 1 while (mask & (1 << shift)).zero?
198
+ values = values.map do |v|
199
+ fail_format("BIT_MASK requires integer storage") unless v.is_a?(Integer)
200
+ (v & mask) >> shift
201
+ end
202
+ end
203
+ values.map { |v| @conversion.convert(conversion, v) }
204
+ end
205
+
206
+ def shared_axis(name, stack)
207
+ fail_format("Shared axis cycle #{(stack + [name]).join(' -> ')}") if stack.include?(name)
208
+ return @axis_cache[name] if @axis_cache.key?(name)
209
+ block = @shared[name] || fail_format("Unknown AXIS_PTS_REF #{name}")
210
+ reference = option(block, "AXIS_PTS_REF")&.first
211
+ if reference
212
+ other = shared_axis(reference, stack + [name])
213
+ values = other.xvalue
214
+ else
215
+ dims = [number(block.values[7]), 1]
216
+ data = deposit(block, block.values[4], block.values[2], dims)
217
+ raw = data["AXIS_PTS_X"] || fail_format("Missing AXIS_PTS_X for #{name}")
218
+ values = converted(block, raw, block.values[6])
219
+ end
220
+ maximum = number(block.values[7])
221
+ fail_format("Shared axis count exceeds maximum") unless maximum.positive? && values.size <= maximum
222
+ @axis_cache[name] = Ecu::Stuetzstellenverteilung.new(**metadata(block), xdim: values.size,
223
+ xvalue: values, xunit: unit(block, block.values[6]))
224
+ end
225
+
226
+ def fixed_axis(axis, count)
227
+ list = axis.children.find { |b| b.kind == "FIX_AXIS_PAR_LIST" }&.values
228
+ if list
229
+ fail_format("Fixed axis count mismatch") unless list.size == count
230
+ return list.map { |v| numeric(v) }
231
+ end
232
+ if (params = option(axis, "FIX_AXIS_PAR", 3))
233
+ offset, shift, size = params.map { |v| number(v) }
234
+ fail_format("Nonfinite fixed axis distance") if shift > 1023
235
+ distance = shift.negative? ? 2.0**shift : 2**shift
236
+ elsif (params = option(axis, "FIX_AXIS_PAR_DIST", 3))
237
+ offset, distance = params.first(2).map { |v| numeric(v) }
238
+ size = number(params[2])
239
+ else
240
+ fail_format("Missing fixed axis parameters")
241
+ end
242
+ fail_format("Fixed axis count mismatch") unless size == count
243
+ count.times.map do |i|
244
+ value = offset + i * distance
245
+ fail_format("Nonfinite fixed axis value") if value.is_a?(Float) && !value.finite?
246
+ value
247
+ end
248
+ end
249
+
250
+ def axis_values(axis, count, raw)
251
+ fail_format("Unsupported DEPOSIT RELATIVE") if option(axis, "DEPOSIT")&.first == "RELATIVE"
252
+ conversion = axis.values[2]
253
+ case axis.values.first
254
+ when "STD_AXIS"
255
+ fail_format("Missing inline axis points") unless raw
256
+ fail_format("Inline axis count mismatch") unless raw.size == count
257
+ [converted(axis, raw, conversion), unit(axis, conversion)]
258
+ when "COM_AXIS"
259
+ reference = option(axis, "AXIS_PTS_REF")&.first || fail_format("Missing AXIS_PTS_REF")
260
+ shared = shared_axis(reference, [])
261
+ fail_format("Shared axis count mismatch") unless shared.xdim == count
262
+ [shared.xvalue, shared.xunit]
263
+ when "FIX_AXIS"
264
+ [converted(axis, fixed_axis(axis, count), conversion), unit(axis, conversion)]
265
+ else
266
+ fail_format("Unsupported axis type #{axis.values.first}")
267
+ end
268
+ end
269
+
270
+ def rows(values, dimensions, direction)
271
+ x, y = dimensions
272
+ direction == "COLUMN_DIR" ? y.times.map { |j| x.times.map { |i| values[i * y + j] } } : values.each_slice(x).to_a
273
+ end
274
+
275
+ def characteristic(block)
276
+ type, address, record, conversion = block.values.values_at(2, 3, 4, 6)
277
+ fail_format("Unsupported calibration type #{type}") unless %w[VALUE ASCII VAL_BLK CURVE MAP].include?(type)
278
+ axes = block.children.select { |b| b.kind == "AXIS_DESCR" }
279
+ matrix = option(block, "MATRIX_DIM", 3)
280
+ if matrix
281
+ matrix = matrix.take_while { |v| v.match?(/\A[+-]?(?:0x[0-9a-f]+|\d+)\z/i) }.map { |v| number(v) }
282
+ fail_format("Invalid array dimensions") if matrix.empty? || matrix.any? { |d| d <= 0 }
283
+ fail_format("Unsupported array dimensions") if matrix.drop(2).any? { |d| d != 1 }
284
+ fail_format("Unsupported scalar dimensions") if type == "VALUE" && matrix.any? { |d| d != 1 }
285
+ fail_format("Unsupported ASCII dimensions") if type == "ASCII" && matrix.drop(1).any? { |d| d != 1 }
286
+ end
287
+ dimensions = if %w[CURVE MAP].include?(type)
288
+ expected = type == "CURVE" ? 1 : 2
289
+ fail_format("Unsupported axis dimensions") unless axes.size == expected
290
+ axes.map { |axis| number(axis.values[3]) }.tap { |d| d << 1 if d.size == 1 }
291
+ elsif type == "VALUE"
292
+ [1, 1]
293
+ else
294
+ dims = matrix
295
+ dims ||= [number(option(block, "NUMBER")&.first || fail_format("Missing NUMBER/MATRIX_DIM"))]
296
+ fail_format("Unsupported array dimensions") if dims.drop(2).any? { |d| d != 1 }
297
+ [dims[0], dims[1] || 1]
298
+ end
299
+ if matrix && %w[CURVE MAP].include?(type)
300
+ fail_format("MATRIX_DIM axis dimensions mismatch") unless [matrix[0], matrix[1] || 1] == dimensions
301
+ end
302
+ axes.each_with_index do |axis, i|
303
+ next unless axis.values.first == "COM_AXIS"
304
+ reference = option(axis, "AXIS_PTS_REF")&.first || fail_format("Missing AXIS_PTS_REF")
305
+ count = shared_axis(reference, []).xdim
306
+ fail_format("Shared axis count exceeds maximum") if count > dimensions[i]
307
+ dimensions[i] = count
308
+ end
309
+ data = deposit(block, record, address, dimensions, ascii: type == "ASCII")
310
+ raw = data["FNC_VALUES"] || fail_format("Missing FNC_VALUES")
311
+ meta = metadata(block).merge(unit: unit(block, conversion))
312
+ return Ecu::Festwert.new(**meta, value: raw.split("\0", 2).first || "") if type == "ASCII"
313
+ values = converted(block, raw, conversion)
314
+ case type
315
+ when "VALUE" then Ecu::Festwert.new(**meta, value: values.first)
316
+ when "VAL_BLK"
317
+ Ecu::Festwerteblock.new(**meta, xdim: dimensions[0], ydim: dimensions[1],
318
+ value: rows(values, dimensions, data["direction"]))
319
+ else
320
+ xvalue, xunit = axis_values(axes[0], dimensions[0], data["AXIS_PTS_X"])
321
+ meta.merge!(xdim: dimensions[0], xvalue: xvalue, xunit: xunit)
322
+ if type == "CURVE"
323
+ Ecu::Kennlinie.new(**meta, value: values)
324
+ else
325
+ yvalue, yunit = axis_values(axes[1], dimensions[1], data["AXIS_PTS_Y"])
326
+ Ecu::Kennfeld.new(**meta, ydim: dimensions[1], yvalue: yvalue, yunit: yunit,
327
+ value: rows(values, dimensions, data["direction"]))
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Ecu
4
+ class A2lParserError < StandardError
5
+ end
6
+ end
@@ -1,18 +1,10 @@
1
- require_relative "../../../core_ext"
1
+ require_relative "a2l_parser"
2
2
 
3
3
  class Ecu
4
4
  class LabelList
5
- A2LREGEXP = %r{/begin CHARACTERISTIC\s+([\S]+)\s+"([^"]*)"}
6
-
7
- def self.from_a2l(str)
8
- str.gsub!(%r{/\*.*?\*/}, "")
9
- labels = str.scan(A2LREGEXP).map do |name, description|
10
- # Ugly hack: Create dummy Festwert until better parsing
11
- # is implemented
12
- Ecu::Festwert.new(name: name, description: description, value: 0)
13
- end
14
- new(labels)
5
+ def self.from_a2l(a2l_text, s19_text = nil, address_map: nil)
6
+ raise ArgumentError, "S19 text is required for A2L calibration values" if s19_text.nil?
7
+ new(A2lParser.new(a2l_text, s19_text, address_map: address_map).call)
15
8
  end
16
9
  end
17
10
  end
18
-
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "a2l_parser_error"
4
+
5
+ class Ecu
6
+ class LabelList
7
+ class S19Image
8
+ TYPES = {
9
+ "UBYTE" => [1, "C", "C"],
10
+ "SBYTE" => [1, "c", "c"],
11
+ "UWORD" => [2, "S<", "S>"],
12
+ "SWORD" => [2, "s<", "s>"],
13
+ "ULONG" => [4, "L<", "L>"],
14
+ "SLONG" => [4, "l<", "l>"],
15
+ "A_UINT64" => [8, "Q<", "Q>"],
16
+ "A_INT64" => [8, "q<", "q>"],
17
+ "FLOAT32_IEEE" => [4, "e", "g"],
18
+ "FLOAT64_IEEE" => [8, "E", "G"]
19
+ }.transform_values(&:freeze).freeze
20
+
21
+ ADDRESS_SIZES = { 0 => 2, 1 => 2, 2 => 3, 3 => 4, 5 => 2, 6 => 3, 7 => 4, 8 => 3, 9 => 2 }.freeze
22
+ private_constant :ADDRESS_SIZES
23
+
24
+ def initialize(text, address_map: nil)
25
+ fail A2lParserError, "S19 text must be a String" unless text.is_a?(String)
26
+ unless text.encoding.ascii_compatible? && text.valid_encoding?
27
+ fail A2lParserError, "Invalid S19 text encoding"
28
+ end
29
+ unless address_map.nil? || address_map.respond_to?(:call)
30
+ fail A2lParserError, "S19 address mapper must be callable"
31
+ end
32
+ @address_map = address_map
33
+ segments = []
34
+ counts = []
35
+ data_records = 0
36
+ text.each_line.with_index(1) do |line, number|
37
+ line = line.strip
38
+ next if line.empty?
39
+ type, address, data = parse_record(line, number)
40
+ case type
41
+ when 1, 2, 3
42
+ data_records += 1
43
+ segments << [address, data, number] unless data.empty?
44
+ when 5, 6
45
+ counts << [address, number]
46
+ end
47
+ end
48
+ counts.each do |count, line|
49
+ fail A2lParserError, "S19 data record count mismatch at line #{line}" unless count == data_records
50
+ end
51
+ fail A2lParserError, "S19 image contains no data" if segments.empty?
52
+ @segments = normalize_segments(segments).freeze
53
+ end
54
+
55
+ def read(address, length)
56
+ validate_integer(address, "address")
57
+ validate_integer(length, "length")
58
+ address = mapped_address(address)
59
+ return "".b if length.zero?
60
+
61
+ # Find the first segment whose end is beyond the requested address.
62
+ index = @segments.bsearch_index { |start, data| start + data.bytesize > address }
63
+ result = "".b
64
+ remaining = length
65
+ while remaining.positive?
66
+ segment = index && @segments[index]
67
+ unless segment && segment[0] <= address
68
+ fail A2lParserError, "S19 missing bytes at address 0x#{address.to_s(16)}"
69
+ end
70
+ start, data = segment
71
+ offset = address - start
72
+ size = [remaining, data.bytesize - offset].min
73
+ result << data.byteslice(offset, size)
74
+ remaining -= size
75
+ address += size
76
+ index += 1
77
+ end
78
+ result
79
+ end
80
+
81
+ def values(address, type, count, byte_order)
82
+ definition = TYPES[type]
83
+ fail A2lParserError, "Unsupported S19 data type #{type.inspect}" unless definition
84
+ unless ["MSB_LAST", "MSB_FIRST"].include?(byte_order)
85
+ fail A2lParserError, "Unsupported S19 byte order #{byte_order.inspect}"
86
+ end
87
+ validate_integer(count, "count")
88
+ size, little, big = definition
89
+ read(address, size * count).unpack("#{byte_order == 'MSB_LAST' ? little : big}*")
90
+ end
91
+
92
+ private
93
+
94
+ def validate_integer(value, name)
95
+ unless value.is_a?(Integer) && value >= 0
96
+ fail A2lParserError, "S19 #{name} must be a nonnegative Integer"
97
+ end
98
+ end
99
+
100
+ def mapped_address(address)
101
+ return address unless @address_map
102
+ mapped = @address_map.call(address)
103
+ validate_integer(mapped, "mapped address")
104
+ mapped
105
+ end
106
+
107
+ def parse_record(line, number)
108
+ unless /\AS[0-9][0-9A-Fa-f]+\z/.match?(line) && (line.bytesize - 2).even?
109
+ fail A2lParserError, "Invalid S19 hex record at line #{number}"
110
+ end
111
+ type = line.getbyte(1) - 48
112
+ width = ADDRESS_SIZES[type]
113
+ fail A2lParserError, "Unknown S19 record type S#{type} at line #{number}" unless width
114
+ bytes = [line.byteslice(2..)].pack("H*").bytes
115
+ count = bytes.first
116
+ unless count == bytes.length - 1 && count >= width + 1
117
+ fail A2lParserError, "S19 record count/length mismatch at line #{number}"
118
+ end
119
+ unless (bytes.sum & 255) == 255
120
+ fail A2lParserError, "S19 checksum mismatch at line #{number}"
121
+ end
122
+ address = bytes.slice(1, width).reduce(0) { |value, byte| (value << 8) | byte }
123
+ data = bytes.slice(width + 1, count - width - 1).pack("C*")
124
+ if type >= 5 && !data.empty?
125
+ fail A2lParserError, "Invalid S19 metadata payload length at line #{number}"
126
+ end
127
+ [type, address, data]
128
+ end
129
+
130
+ def normalize_segments(segments)
131
+ normalized = []
132
+ segments.sort_by! { |start, _, _| start }
133
+ segments.each do |start, data, line|
134
+ index = normalized.bsearch_index { |position, bytes| position + bytes.bytesize > start }
135
+ consumed = 0
136
+ while index && index < normalized.length && consumed < data.bytesize
137
+ previous_start, previous_data = normalized[index]
138
+ overlap = [previous_start + previous_data.bytesize - start, data.bytesize - consumed].min
139
+ unless previous_data.byteslice(start - previous_start, overlap) == data.byteslice(consumed, overlap)
140
+ fail A2lParserError, "Conflicting S19 overlap at address 0x#{start.to_s(16)}, line #{line}"
141
+ end
142
+ start += overlap
143
+ consumed += overlap
144
+ index += 1
145
+ end
146
+ data = data.byteslice(consumed..) if consumed.positive?
147
+ normalized << [start, data.freeze].freeze unless data.empty?
148
+ end
149
+ normalized
150
+ end
151
+ end
152
+ end
153
+ end
@@ -5,7 +5,9 @@ class Ecu
5
5
  class CombinedList
6
6
  def self.from_lab(str)
7
7
  signals, labels, headers, subheaders = LabParser.new(str).call
8
- new(signals, labels, headers, subheaders)
8
+ new(SignalList.new(signals, headers, subheaders),
9
+ LabelList.new(labels, headers, subheaders),
10
+ headers, subheaders)
9
11
  end
10
12
 
11
13
  def to_lab
@@ -26,7 +28,7 @@ class Ecu
26
28
  out.append("[Label]")
27
29
  out.append(*labels.map(&:to_lab))
28
30
 
29
- out.join("\n")
31
+ out.join("\n") + "\n"
30
32
  end
31
33
  end
32
34
  end
@@ -19,10 +19,12 @@ class Ecu
19
19
  class LabelList
20
20
  include Enumerable
21
21
 
22
- def self.from_file(file_path)
22
+ def self.from_file(file_path, s19_path: nil, address_map: nil)
23
23
  case File.extname(file_path)
24
24
  when ".lab" then self.from_lab(File.read_encoded(file_path))
25
- when ".a2l" then self.from_a2l(File.read_encoded(file_path))
25
+ when ".a2l"
26
+ raise ArgumentError, "S19 path is required for A2L calibration values" if s19_path.nil?
27
+ self.from_a2l(File.read_encoded(file_path), File.read_encoded(s19_path), address_map: address_map)
26
28
  when ".dcm" then self.from_dcm(File.read_encoded(file_path))
27
29
  else fail "Unknown file extension: #{file_path}!"
28
30
  end
@@ -6,8 +6,8 @@ class Ecu
6
6
  fail "Can only merge SignalLists (left: #{left.class}, right: #{right.class})!"
7
7
  end
8
8
 
9
- @left = left.map(&:name)
10
- @right = right.map(&:name)
9
+ @left = left.map(&:name).zip(left).to_h
10
+ @right = right.map(&:name).zip(right).to_h
11
11
  @unified_headers = left.headers + right.headers
12
12
  @unified_subheaders = left.subheaders + right.subheaders
13
13
  @names = Hash.new
@@ -16,10 +16,10 @@ class Ecu
16
16
  def names(criterion)
17
17
  @names[criterion] ||= \
18
18
  case criterion
19
- when :left_exclusive then left - right
20
- when :right_exclusive then right - left
21
- when :common then left & right
22
- when :equal then left & right
19
+ when :left_exclusive then left.keys - right.keys
20
+ when :right_exclusive then right.keys - left.keys
21
+ when :common then left.keys & right.keys
22
+ when :equal then left.keys & right.keys
23
23
  when :nonequal then []
24
24
  else fail "Unknown criterion #{criterion}"
25
25
  end
@@ -38,7 +38,7 @@ class Ecu
38
38
  (
39
39
  names(:left_exclusive).map { |n| left.fetch(n) } +
40
40
  names(:right_exclusive).map { |n| right.fetch(n) } +
41
- names(:common).map { |n| left.fetch(n) }
41
+ names(:common).map { |n| (priority == :right ? right : left).fetch(n) }
42
42
  ),
43
43
  @unified_headers,
44
44
  @unified_subheaders
data/lib/ecu/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Ecu
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: automotive-ecu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Mueller
@@ -96,7 +96,12 @@ files:
96
96
  - lib/ecu.rb
97
97
  - lib/ecu/combined_list.rb
98
98
  - lib/ecu/combined_list_comparison.rb
99
+ - lib/ecu/interfaces/a2l/a2l_conversion.rb
100
+ - lib/ecu/interfaces/a2l/a2l_document.rb
101
+ - lib/ecu/interfaces/a2l/a2l_parser.rb
102
+ - lib/ecu/interfaces/a2l/a2l_parser_error.rb
99
103
  - lib/ecu/interfaces/a2l/label_list.rb
104
+ - lib/ecu/interfaces/a2l/s19_image.rb
100
105
  - lib/ecu/interfaces/a2l/signal_list.rb
101
106
  - lib/ecu/interfaces/dbc/signal_list.rb
102
107
  - lib/ecu/interfaces/dcm/dcm_parser.rb