Packages

An elixir library for MAVLink, an application that enables communication with other systems using the MAVLink protocol over serial, UDP and TCP connections, and utility modules for performing common MAVLink commands and tasks with one or more remote vehicles.

Current section

Files

Jump to
xmavlink lib mavlink parser.ex
Raw

lib/mavlink/parser.ex

defmodule XMAVLink.Parser do
@moduledoc """
Parse a mavlink xml file into an idiomatic Elixir representation:
%{
version: 2,
dialect: 0,
enums: [
%{
name: :mav_autopilot,
description: "Micro air vehicle...",
entries: [
%{
value: 0,
name: :mav_autopilot_generic, (use atoms for identifiers)
description: "Generic autopilot..."
params: [ (only used by commands)
%{
index: 0,
description: ""
},
... more entry params
]
},
... more enum entries
]
},
... more enums
],
messages: [
%{
id: 0,
name: "optical_flow",
description: "Optical flow...",
fields: [
%{
type: "uint16_t",
ordinality: 1,
name: "flow_x",
units: "dpixels", (note: string not atom)
description: "Flow in pixels..."
},
... more message fields
]
},
... more messages
]
}
"""
import Enum, only: [empty?: 1, reduce: 3, reverse: 1, map: 2, sort_by: 2, into: 3, filter: 2]
import Record, only: [defrecord: 2, extract: 2]
import Regex, only: [replace: 3]
import String, only: [to_integer: 1, downcase: 1, to_atom: 1, split: 3]
@identifier_regex ~r/\A[A-Za-z][A-Za-z0-9_]*\z/
@unit_regex ~r/\A[A-Za-z0-9_%@\/\*\^\.\-]+\z/
@scalar_field_types ~w(char uint8_t int8_t uint16_t int16_t uint32_t int32_t uint64_t int64_t float double)
@valid_display_values ~w(bitmask)
@xmerl_header "xmerl/include/xmerl.hrl"
defrecord :xmlElement, extract(:xmlElement, from_lib: @xmerl_header)
defrecord :xmlAttribute, extract(:xmlAttribute, from_lib: @xmerl_header)
defrecord :xmlText, extract(:xmlText, from_lib: @xmerl_header)
@type mavlink_definition :: %{
version: String.t(),
dialect: String.t(),
enums: [enum_description],
messages: [message_description]
}
@spec parse_mavlink_xml(String.t()) ::
mavlink_definition | {:error, String.t()}
def parse_mavlink_xml(path) do
case parse_mavlink_xml(path, %{seen: MapSet.new(), definitions: []}) do
{:error, _message} = error ->
error
%{definitions: definitions} ->
definitions
|> reverse()
|> combine_definitions()
|> validate_definition()
end
end
def parse_mavlink_xml(path, paths) do
acc =
if Map.has_key?(paths, :seen) and Map.has_key?(paths, :definitions) do
paths
else
%{seen: MapSet.new(Map.keys(paths)), definitions: Map.values(paths)}
end
parse_mavlink_xml_file(path, acc)
end
defp parse_mavlink_xml_file(path, acc) do
path_key = Path.expand(path)
case MapSet.member?(acc.seen, path_key) do
true ->
# Don't include a file twice
acc
false ->
case scan_file(path) do
{defs, []} ->
acc = %{acc | seen: MapSet.put(acc.seen, path_key)}
with {:ok, acc} <- parse_includes(defs, path, acc),
{:ok, definition} <- parse_definition(defs) do
%{acc | definitions: [definition | acc.definitions]}
end
{:error, :enoent} ->
{:error, "File '#{path}' does not exist"}
{:error, message} when is_binary(message) ->
{:error, message}
{:error, message} ->
{:error, "Failed to parse MAVLink XML file '#{path}': #{inspect(message)}"}
end
end
end
defp parse_includes(defs, path, acc) do
:xmerl_xpath.string(~c"/mavlink/include/text()", defs)
|> map(&extract_text/1)
|> reduce({:ok, acc}, fn
_next_include, {:error, _message} = error ->
error
next_include, {:ok, acc} ->
include_path = Path.expand(next_include, Path.dirname(path))
case parse_mavlink_xml_file(include_path, acc) do
{:error, _message} = error -> error
acc -> {:ok, acc}
end
end)
end
defp parse_definition(defs) do
with {:ok, enums} <-
parse_elements(:xmerl_xpath.string(~c"/mavlink/enums/enum", defs), &parse_enum/1) do
version =
:xmerl_xpath.string(~c"/mavlink/version/text()", defs)
|> extract_text
|> nil_to_zero_string
case parse_elements(
:xmerl_xpath.string(~c"/mavlink/messages/message", defs),
&parse_message(&1, version)
) do
{:ok, messages} ->
{:ok,
%{
version: version,
dialect:
:xmerl_xpath.string(~c"/mavlink/dialect/text()", defs)
|> extract_text
|> nil_to_zero_string,
enums: enums,
messages: messages
}}
{:error, _message} = error ->
error
end
end
end
defp scan_file(path) do
try do
:xmerl_scan.file(path)
catch
kind, reason ->
{:error, "Failed to parse MAVLink XML file '#{path}': #{inspect({kind, reason})}"}
end
end
def combine_definitions([single_def]) do
single_def
end
def combine_definitions([
%{
version: v1,
dialect: d1,
enums: e1,
messages: m1
},
%{
version: v2,
dialect: d2,
enums: e2,
messages: m2
}
| more_definitions
]) do
combine_definitions([
%{
# strings > nil
version: max(v1, v2),
dialect: max(d1, d2),
enums: merge_enums(e1, e2),
messages: sort_by(m1 ++ m2, & &1.id)
}
| more_definitions
])
end
def merge_enums(as, bs) do
a_index = into(as, %{}, fn enum -> {enum.name, enum} end)
b_index = into(bs, %{}, fn enum -> {enum.name, enum} end)
only_in_a =
for name <- filter(Map.keys(a_index), &(!Map.has_key?(b_index, &1))), do: a_index[name]
only_in_b =
for name <- filter(Map.keys(b_index), &(!Map.has_key?(a_index, &1))), do: b_index[name]
in_a_and_b =
for name <- filter(Map.keys(a_index), &Map.has_key?(b_index, &1)) do
%{
a_index[name]
| description:
preferred_description(a_index[name].description, b_index[name].description),
bitmask: a_index[name].bitmask or b_index[name].bitmask,
entries: sort_by(a_index[name].entries ++ b_index[name].entries, & &1.value)
}
end
sort_by(only_in_a ++ in_a_and_b ++ only_in_b, & &1.name)
end
@type enum_description :: %{
name: atom,
bitmask: boolean,
description: String.t(),
entries: [entry_description]
}
@spec parse_enum(tuple) :: {:ok, enum_description} | {:error, String.t()}
defp parse_enum(element) do
raw_name = :xmerl_xpath.string(~c"@name", element) |> extract_text
with {:ok, enum_name} <- required_identifier(raw_name, "enum name", "enum"),
{:ok, entries} <-
parse_elements(
:xmerl_xpath.string(~c"/enum/entry", element),
&parse_entry(&1, enum_name)
) do
{:ok,
%{
name: enum_name |> downcase |> to_atom,
bitmask: :xmerl_xpath.string(~c"@bitmask", element) |> extract_text |> true_string?,
description:
:xmerl_xpath.string(~c"/enum/description/text()", element)
|> extract_text
|> nil_to_empty_string,
entries: entries
}}
end
end
@type entry_description :: %{
value: integer | nil,
name: atom,
description: String.t(),
params: [param_description]
}
@spec parse_entry(tuple, String.t()) :: {:ok, entry_description} | {:error, String.t()}
defp parse_entry(element, enum_name) do
# Apparently optional in common.xml?
value_attr = :xmerl_xpath.string(~c"@value", element)
raw_name = :xmerl_xpath.string(~c"@name", element) |> extract_text
context = "enum #{enum_name}"
with {:ok, entry_name} <- required_identifier(raw_name, "enum entry name", context),
{:ok, value} <-
optional_integer(value_attr, "enum entry value", "#{context} entry #{entry_name}"),
{:ok, params} <-
parse_elements(
:xmerl_xpath.string(~c"/entry/param", element),
&parse_param(&1, entry_name)
) do
{:ok,
%{
value: value,
name: entry_name |> downcase |> to_atom,
description:
:xmerl_xpath.string(~c"/entry/description/text()", element)
|> extract_text
|> nil_to_empty_string,
params: params
}}
end
end
@type param_description :: %{
index: integer,
description: String.t()
}
@spec parse_param(tuple, String.t()) :: {:ok, param_description} | {:error, String.t()}
defp parse_param(element, entry_name) do
with {:ok, index} <-
required_integer(
:xmerl_xpath.string(~c"@index", element) |> extract_text,
"param index",
"entry #{entry_name}"
) do
{:ok,
%{
index: index,
description:
:xmerl_xpath.string(~c"/param/text()", element)
|> extract_text
|> nil_to_empty_string
}}
end
end
@type message_description :: %{
id: integer,
name: String.t(),
description: String.t(),
has_ext_fields: boolean,
fields: [field_description]
}
@spec parse_message(tuple, String.t()) :: {:ok, message_description} | {:error, String.t()}
defp parse_message(element, version) do
raw_name = :xmerl_xpath.string(~c"@name", element) |> extract_text
with {:ok, message_id} <-
required_integer(
:xmerl_xpath.string(~c"@id", element) |> extract_text,
"message id",
"message"
),
{:ok, message_name} <-
required_identifier(raw_name, "message name", "message id #{message_id}") do
message_description =
reduce(
xmlElement(element, :content),
%{
id: message_id,
name: message_name,
description:
:xmerl_xpath.string(~c"/message/description/text()", element)
|> extract_text
|> nil_to_empty_string,
has_ext_fields: false,
fields: []
},
fn
_next_child, {:error, _message} = error ->
error
next_child, acc ->
case xmlElement(next_child, :name) do
:field ->
case parse_field(next_child, version, acc.has_ext_fields, message_name) do
{:ok, field} -> %{acc | fields: [field | acc.fields]}
{:error, _message} = error -> error
end
:extensions ->
%{acc | has_ext_fields: true}
_ ->
acc
end
end
)
case message_description do
{:error, _message} = error ->
error
message_description ->
{:ok, %{message_description | fields: reverse(message_description.fields)}}
end
end
end
@type field_description :: %{
type: String.t(),
ordinality: integer,
omit_arg: boolean,
is_extension: boolean,
constant_val: String.t() | nil,
name: String.t(),
enum: String.t(),
display: :bitmask | nil,
print_format: String.t() | nil,
units: atom | nil,
description: String.t()
}
@spec parse_field(tuple, binary(), boolean, String.t()) ::
{:ok, field_description} | {:error, String.t()}
defp parse_field(element, version, is_extension_field, message_name) do
context = "message #{message_name}"
with {:ok, {type, ordinality, omit_arg, constant_val}} <-
:xmerl_xpath.string(~c"@type", element)
|> extract_text
|> parse_type_ordinality_omit_arg_constant_val(version, context),
{:ok, field_name} <-
:xmerl_xpath.string(~c"@name", element)
|> extract_text
|> required_identifier("field name", context),
{:ok, enum} <-
:xmerl_xpath.string(~c"@enum", element)
|> extract_text
|> optional_identifier("field enum", "#{context} field #{field_name}"),
{:ok, display} <-
:xmerl_xpath.string(~c"@display", element)
|> extract_text
|> optional_display("#{context} field #{field_name}"),
{:ok, units} <-
:xmerl_xpath.string(~c"@units", element)
|> extract_text
|> optional_unit("#{context} field #{field_name}") do
{:ok,
%{
type: type,
ordinality: ordinality,
omit_arg: omit_arg,
is_extension: is_extension_field,
constant_val: constant_val,
# You can't downcase this, wrecks crc_extra calc for POWER_STATUS
name: field_name,
enum: enum |> nil_to_empty_string |> downcase,
display: display,
print_format: :xmerl_xpath.string(~c"@print_format", element) |> extract_text,
units: units,
description:
:xmerl_xpath.string(~c"/field/text()", element) |> extract_text |> nil_to_empty_string
}}
end
end
@spec parse_type_ordinality_omit_arg_constant_val(String.t(), String.t(), String.t()) ::
{:ok, {String.t(), integer, boolean, String.t() | nil}} | {:error, String.t()}
defp parse_type_ordinality_omit_arg_constant_val(nil, _version, context) do
{:error, "Missing field type in #{context}"}
end
defp parse_type_ordinality_omit_arg_constant_val(type_string, version, context) do
[type | ordinality] =
type_string
|> split(["[", "]"], trim: true)
case type do
"uint8_t_mavlink_version" ->
{:ok, {"uint8_t", 1, true, version}}
type when type in @scalar_field_types ->
with {:ok, ordinality} <- parse_ordinality(ordinality, context) do
{:ok, {type, ordinality, false, nil}}
end
_ ->
{:error, "Invalid field type #{inspect(type_string)} in #{context}"}
end
end
defp parse_ordinality([], _context), do: {:ok, 1}
defp parse_ordinality([raw_ordinality], context) do
case required_integer(raw_ordinality, "field array length", context) do
{:ok, ordinality} when ordinality > 0 ->
{:ok, ordinality}
{:ok, _ordinality} ->
{:error, "Invalid field array length #{inspect(raw_ordinality)} in #{context}"}
{:error, _message} = error ->
error
end
end
defp parse_ordinality(_ordinality, context),
do: {:error, "Invalid field type array declaration in #{context}"}
defp parse_elements(elements, parser) do
elements
|> reduce({:ok, []}, fn
_element, {:error, _message} = error ->
error
element, {:ok, acc} ->
case parser.(element) do
{:ok, parsed} -> {:ok, [parsed | acc]}
{:error, _message} = error -> error
end
end)
|> case do
{:ok, parsed} -> {:ok, reverse(parsed)}
{:error, _message} = error -> error
end
end
defp validate_definition(definition) do
with :ok <- validate_unique_message_ids(definition.messages),
:ok <- validate_unique_message_modules(definition.messages),
:ok <- validate_unique_enums(definition.enums),
:ok <- validate_enum_entries(definition.enums),
:ok <- validate_message_fields(definition.messages) do
definition
end
end
defp validate_unique_message_ids(messages) do
case duplicate_by(messages, & &1.id) do
nil ->
:ok
{id, duplicate_messages} ->
names = duplicate_messages |> map(& &1.name) |> Enum.join(", ")
{:error, "Duplicate message id #{id} for #{names}"}
end
end
defp validate_unique_message_modules(messages) do
case duplicate_by(messages, &generated_message_module_name(&1.name)) do
nil ->
:ok
{module_name, duplicate_messages} ->
names = duplicate_messages |> map(& &1.name) |> Enum.join(", ")
{:error, "Duplicate generated message module #{module_name} for #{names}"}
end
end
defp validate_unique_enums(enums) do
case duplicate_by(enums, & &1.name) do
nil ->
:ok
{name, _duplicate_enums} ->
{:error, "Duplicate enum #{inspect(name)}"}
end
end
defp validate_enum_entries(enums) do
reduce(enums, :ok, fn
_enum, {:error, _message} = error ->
error
enum, :ok ->
with :ok <- validate_unique_enum_entry_names(enum),
:ok <- validate_unique_enum_entry_values(enum) do
:ok
end
end)
end
defp validate_unique_enum_entry_names(enum) do
case duplicate_by(enum.entries, & &1.name) do
nil ->
:ok
{name, _duplicate_entries} ->
{:error, "Duplicate enum entry #{inspect(name)} in enum #{inspect(enum.name)}"}
end
end
defp validate_unique_enum_entry_values(enum) do
resolved_entries = resolve_enum_entry_values(enum.entries)
case duplicate_by(resolved_entries, & &1.value) do
nil ->
:ok
{value, duplicate_entries} ->
names = duplicate_entries |> map(&inspect(&1.name)) |> Enum.join(", ")
{:error, "Duplicate enum value #{value} in enum #{inspect(enum.name)} for #{names}"}
end
end
defp validate_message_fields(messages) do
reduce(messages, :ok, fn
_message, {:error, _reason} = error ->
error
message, :ok ->
case duplicate_by(message.fields, &(&1.name |> downcase)) do
nil ->
:ok
{name, _duplicate_fields} ->
{:error, "Duplicate field #{inspect(name)} in message #{message.name}"}
end
end)
end
defp resolve_enum_entry_values(entries) do
entries
|> Enum.map_reduce(0, fn entry, next_value ->
value = entry.value || next_value
{%{entry | value: value}, value + 1}
end)
|> elem(0)
end
defp duplicate_by(values, fun) do
values
|> Enum.group_by(fun)
|> Enum.find(fn {_key, grouped_values} -> length(grouped_values) > 1 end)
end
defp preferred_description(_a, b) when b not in [nil, ""], do: b
defp preferred_description(a, _b), do: a
defp generated_message_module_name(message_name) do
message_name
|> String.split("_")
|> map(&String.capitalize/1)
|> Enum.join("")
end
defp required_identifier(nil, kind, context), do: {:error, "Missing #{kind} in #{context}"}
defp required_identifier("", kind, context), do: {:error, "Missing #{kind} in #{context}"}
defp required_identifier(value, kind, context) when is_binary(value) do
if Regex.match?(@identifier_regex, value) do
{:ok, value}
else
{:error, "Invalid #{kind} #{inspect(value)} in #{context}"}
end
end
defp optional_identifier(nil, _kind, _context), do: {:ok, nil}
defp optional_identifier("", _kind, _context), do: {:ok, nil}
defp optional_identifier(value, kind, context), do: required_identifier(value, kind, context)
defp optional_display(nil, _context), do: {:ok, nil}
defp optional_display("", _context), do: {:ok, nil}
defp optional_display(value, _context) when value in @valid_display_values do
{:ok, to_atom(value)}
end
defp optional_display(value, context),
do: {:error, "Invalid field display #{inspect(value)} in #{context}"}
defp optional_unit(nil, _context), do: {:ok, nil}
defp optional_unit("", _context), do: {:ok, nil}
defp optional_unit(value, context) do
if Regex.match?(@unit_regex, value) do
{:ok, to_atom(value)}
else
{:error, "Invalid field unit #{inspect(value)} in #{context}"}
end
end
defp optional_integer([], _kind, _context), do: {:ok, nil}
defp optional_integer(value_attr, kind, context) do
if empty?(value_attr) do
{:ok, nil}
else
value_attr
|> extract_text
|> required_integer(kind, context)
end
end
defp required_integer(nil, kind, context), do: {:error, "Missing #{kind} in #{context}"}
defp required_integer("", kind, context), do: {:error, "Missing #{kind} in #{context}"}
defp required_integer(value, kind, context) when is_binary(value) do
{:ok, to_integer(value)}
rescue
ArgumentError -> {:error, "Invalid #{kind} #{inspect(value)} in #{context}"}
end
# TODO Can't spec this without causing dialyzer "nil can't match binary" - Erlang types?
defp extract_text([xml]), do: extract_text(xml)
defp extract_text(xmlText(value: value)), do: clean_string(value)
defp extract_text(xmlAttribute(value: value)), do: clean_string(value)
defp extract_text(_), do: nil
@spec clean_string([char] | binary) :: String.t()
defp clean_string(s) do
trimmed = s |> List.to_string() |> String.trim()
replace(~r/\s+/, trimmed, " ")
end
@spec nil_to_empty_string(String.t() | nil) :: String.t()
defp nil_to_empty_string(nil), do: ""
defp nil_to_empty_string(value) when is_binary(value), do: value
@spec nil_to_zero_string(String.t() | nil) :: String.t()
defp nil_to_zero_string(nil), do: "0"
defp nil_to_zero_string(value) when is_binary(value), do: value
defp true_string?("true"), do: true
defp true_string?(_), do: false
end