Skip to content

Parser Dokumentation

In diesem Dokument wird die grundlegende Funktion und die Implementation eines Parsers auf der ELEMENT IoT Plattform beschrieben.

Allgemein

Auf der ELEMENT IoT Plattform gibt es die Möglichkeit Paketdaten eines Geräts durch hinterlegten Quellcode parsen zu lassen. So lassen sich binäre oder bereits strukturierte Daten in ein gewünschtes Format überführen.

Der verwendete Quellcode ist in der Sprache "Elixir" (https://elixir-lang.org/) zu schreiben, welche sich durch ihre Ausdrucksstärke und die Fähigkeit elegant mit Binärdaten umzugehen auszeichnet. Eine Dokumentation zu der Sprache ist hier zu finden: https://hexdocs.pm/elixir/Kernel.html. Dabei ist jedoch zu beachten, dass nur durch die Plattform selbst freigegebene Funktionen und Module verwendbar sind.

In dem Quellcode des Parsers muss sich eine Funktion Parser.parse/2 befinden, welche für jedes Paket (Packet) aufgerufen wird. Das Resultat dieser Funktion wird dann als Messwert (Reading) von der Plattform gespeichert.

Quellcode

In diesem Abschnitt wird der Weg von der Vorlage zu einem funktionstüchtigen Parser beschrieben.

Vorlage

Die folgende Vorlage wird vom System beim Erstellen eines Parser vorgeschlagen.

defmodule Parser do
  use Platform.Parsing.Behaviour

  def parse(event, meta) do
  end

end

Es ist das Modul Parser mit der darin enthaltenen Funktion parse(event, _meta) vorgegeben.

Die Funktion parse/2 erwartet zwei Argumente, wobei event die binären/strukturierten Daten und meta weitere Metadaten des Pakets enthält.

Rückgabewerte

Ziel der Parser.parse/2 Funktion ist es, Readings aus einem Packet zu erstellen. Es wurden mehrere Wege vorgesehen dies mit nur einem flexibel Rückgabewert zu ermöglichen.

Kein Reading

Wenn kein Reading erstellt werden soll, dann ist schlicht die leere Liste zurückzugeben: [].

Ein Reading

Um ein Reading zu erstellen, kann dieses als Map mit Schlüssel-Wert-Paaren zurückgegeben werden.

Beispiel:

%{
  temp: 42,
  status: :ok,
  battery: 7
}

Dieser Rückgabewert ist die Kurzform einer Liste mit nur einem Element:

[
  %{
    temp: 42,
    status: :ok,
    battery: 7,
  }
]

Beide Schreibweisen können verwendet werden, der Rückgabewert wird effektiv gleich verarbeitet.

Mehrere Readings

Wenn aus einem Packet mehrere Readings entstehen sollen, kann eine Liste an Maps zurückgegeben werden.

Beispiel:

[
  %{
    temp: 42,
    status: :ok,
    battery: 7,
  },
  %{
    temp: 9,
    status: :warn,
    battery: 1,
  },
]

Wenn mehrere Readings zurückgegeben werden müssen, ist dies der einzige Weg, da die Parser.parse/2 nur einmal pro Packet ausgeführt wird.

Reading Parametrisieren

Neben einem Reading können optional weitere Werte zurückgegeben werden, die das Reading um Angaben und ggf. daraus folgenden Funktion anreichern kann.

Dies wird ermöglicht, indem die Map des Readings zusammen mit einer Keyword-List in einem Tuple zurückgegeben wird. Die Struktur sieht dann wie folgt aus: {Map, KeywordList}.

Beispiel:

{
  %{
    temp: 42,
    status: :ok,
    battery: 7,
  },
  [
    arg1: value1,
    arg1: value2,
    # ...
  ]

}

Beispiel in Form einer Liste:

[
  {
    %{
      temp: 42,
      status: :ok,
      battery: 7,
    },
    [
      arg1: value1,
      arg1: value2,
    # ...
    ]
  },
]

Verschiedene Argumente können auch kombiniert werden. Wenn ein Argument mehrfach in der Keyword-List enthalten ist, wird nur das erste Vorkommen beachtet.

Welche Argumente verwendet werden können, wird in den nächsten Abschnitten beschrieben.

Readings Zurückdatierten

Wenn ein Reading zu einem anderen Zeitpunkt gültig sein soll, kann dieser wie folgt angegeben werden.

Der Zeitpunkt wird als DateTime angegeben, welches sich beispielsweise leicht aus einem Unix-Timestamp erzeugen lässt: DateTime.from_unix!(1432109876).

Über das Argument measured_at kann der Zeitpunkt wie folgt zurückgegeben werden:

{
  %{
    temp: 42,
    status: :ok,
    battery: 7,
  },
  [
    measured_at: DateTime.from_unix!(1432109876),
  ]
}

Readings Geräteposition

Es ist möglich für ein Reading einen Standort anhand seiner GPS Koordinaten zu verknüpfen. Dieser Standort kann dann für Tracking oder dynamische Geolocation verwendet werden.

Die GPS Koordinaten können in mehreren Formaten als Argument angegeben werden. Die Reihenfolge ist immer longitude dann latitude.

  • Als Tuple: {9.995381, 53.555035}.

  • Als Geo.Point: %Geo.Point{coordinates: {9.995381, 53.555035}}.

Über das Argument location kann die Position für ein Reading wie folgt zurückgegeben werden:

{
  %{
    temp: 42,
    status: :ok,
    battery: 7,
  },
  [
    location: {9.995381, 53.555035},
  ]
}

Geräteprofil zugreifen

Ein Parser kann auf die Profildaten des Geräts zugreifen und diese beim Verarbeiten der Pakete verwenden. Auf diese Art können für jedes Gerät die Messwerte unterschiedlich berechnet oder eigene Logiken umgesetzt werden.

Damit für dieses Gerät die aktuellen Profildaten verfügbar werden, müssen diese mittels "Preload" geladen werden. Dazu ist folgende Funktion im Parser hinzuzufügen:

  def preloads do
    [device: [profile_data: [:profile]]]
  end

Die Profildaten sind dann im Parameter meta unter device.fields zu finden.

Diese Funktion kann mit der Funktion "Geräteprofil ändern" kombiniert werden, um Werte zu berechnen die vom letzten Profilwert ausgehen und diese wieder im Profil zu speichern.

Beispiel:

Im folgenden Beispiel gibt es ein Profil mit dem technischen Namen adresse und den beiden Feldern strasse und hausnummer die als Zeichenkette definiert sind.

Über die Hilfsfunktion get/3 kann dann wie folgt zugegriffen werden:

adresse_strasse = get(meta, [:device, :fields, :adresse, :strasse])
adresse_hausnummer = get(meta, [:device, :fields, :adresse, :hausnummer])

Geräteprofil ändern

Ein Parser kann auf die Profildaten eines Geräts zugreifen und diese auch ändern. Zum Ändern wird neben den Daten des Messwerts auch eine Option fields zurückgegeben mit den geänderten Profildaten.

In den folgenden Beispielen gibt es ein Profil mit dem technischen Namen adresse und den beiden Feldern strasse und hausnummer die als Zeichenkette definiert sind. Dazu existiert noch ein Profil geraetestatus mit dem Feld letzter_statusals Zeichenkette, welches dem Gerät bisher nicht zugeordnet ist.

Die Struktur der Rückgabeoption fields entspricht der aus meta.device.fields. Es braucht jedoch nicht die komplette Struktur zurückgegeben werden, sondern nur die geänderten Felder.

Beispiel um im Profil adresse die hausnummer auf "42a" zu ändern.

{
  %{
    temp: 42,
    status: :ok,
    battery: 7,
  },
  [
    fields: %{
      adresse: %{
        hausnummer: "42a",
      }
    },
  ]
}

Ein neues Profil lässt sich hinzufügen, indem der Eintrag mit den Feldern zurückgegeben wird. Wenn kein Messwert erzeugt werden soll, kann statt den Daten auch nil angegeben werden.

{
  nil, # Do not create a reading
  [
    fields: %{
      adresse: %{
        strasse: "Hauptstrasse", 
        hausnummer: 42
      },
      geraetestatus: %{ # Add this profile to device
        letzter_status: "ok"
      }
    }
  ]
}

Die Validierung der Profile werden beim Ändern und Hinzufügen beachtet. Wenn ein Profil nicht validiert werden kann, wird dieses nicht geändert. Bei Validierungsfehlern wird eine Fehlermeldung im Protokol des Parsers zu finden sein.

Parsen

In den folgenden Abschnitten wird beschrieben, welche Arten von Daten zu erwarten und wie diese verarbeitet werden können.

Parsen von strukturierten Daten

Wenn beispielsweise der "HTTP REST" Treiber verwendet wird um regelmäßig eine Wetter-API anzufragen, die folgendes JSON antwortet:

{
  "country": "Germany",
  "city": "Hamburg",
  "weather": {
    "temperature": 23,
    "humidity": 55,
    "rain": 1.2
  }
}

Dann wird der Parameter event für die Parser.parse/2 Funktion folgenden Wert annehmen:

%{
  "country" => "Germany",
  "city" => "Hamburg",
  "weather" => %{
    "temperature" => 23,
    "humidity" => 55,
    "rain" => 1.2
  }
}

Die Implementation des Parsers soll jetzt die Temperatur aus dieser Struktur in ein gewünschtes Reading umwandeln.

def parse(event, meta) do
  %{
  temp_celsius: get_in(event, ["weather", "temperature"])
  }
end

Im entstandenen Reading ist jetzt unter dem Schlüssel temp_celsius der Wert 23 gespeichert und kann visualisiert werden.

Weitere Erklärungen zu Pattern-Matching mit Elixir finden sich in diesem Guide.

Parsen von binären Daten

Wenn über einen Treiber ein Paket von z.B. einem LoRaWAN Device empfangen wird, kann die binäre Payload über den Parameter event verarbeitet werden.

Um binäre Daten zu verarbeiten, muss bekannt sein welche Bits und Bytes sich an welcher Position und "Leserichtung" (endianness) befinden. Um dies zu demonstrieren wird ein einfaches binäres Beispielformat verwendet.

  • Der Beispiel-Payload sei genau 16 Bit, also 2 Byte lang.

  • Das ersten 2 Bit müssen den binären Wert 00 haben und beschreiben die Paketversion.

  • Es folgen 2 Bit welche die verschiedene Zustände des Geräts beschreiben:

  • Ob der Selbsttests des Geräts erfolgreich ist.

  • Ob das Gerät an eine Spannungsversorgung angeschlossen ist.

  • Weitere 3 Bit stellen einen Batteriefüllstand zwischen 0..7 dar.

  • Die restlichen 9 Bit beinhalten einen Messwert zwischen 0..511 in umgekehrter Bitreihenfolge (little endian).

Darstellung der Bits mit dem dazugehörigem Index und Bezeichner:

Index: 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16

Bits: 0 0 = Paketversion

1 = Selftest: OK

0 = Stromanschluss: NO

1 0 1 = Battery Level: 5 (big endian)

0 0 1 0 1 0 1 0 0 = Meter Value: 84 (little endian)

Der Payload selber wird auf der Platform in HEX Schreibweise dargestellt, was in diesem Fall "2A54" ist.

Die Funktion zum Parsen dieser binären Daten sieht wie folgt aus:

def parse(<<0::1, 0::1, selftest::1, powersupply::1, battery_level::3, meter_value::little-9>>, meta) do
  %{
    packet_version: 0,
    selftest_ok: (selftest == 1),
    powersupply: (powersupply == 1),
    battery_level: battery_level,
    meter_value: meter_value,
  }
end

Es lässt sich gut erkennen, wie die schematische Darstellung der Bits direkt in Code übersetzt werden kann.

Ein paar Hinweise zum Verständnis:

  • Die Notation << >> beschreibt einen binären Wert, in dem sich Platzhalter für einzelne Bits/Bytes befinden können die als nächstes beschrieben werden.

  • Ein 0::1 erwartet den Wert 0 mit einer Länge von einem Bit.

  • Ein selftest::1 wird den Wert in die Variable selftest zuweisen. Der Wert hat eine Länge von einem Bit.

  • Ein battery_level::3 wird den Wert von drei Bit, also den Wertebereich von 0..7, in battery_level speichern.

  • Ein meter_value::little-9 wird den Wert von neun Bit als little-endian in meter_value speichern.

  • Wenn der Payload von der Länge oder den festen Bits nicht passt, wird der Funktionskörper nicht ausgeführt und es entsteht kein Messwert.

Eine vollständige Dokumentation zu den möglichen Platzhaltern findet sich hier: https://hexdocs.pm/elixir/Kernel.SpecialForms.html#%3C%3C%3E%3E/1

Weitere Formate

Es lassen sich mit Elixir leicht mehrere Packetversionen programmieren, die der Parser gleichzeitig unterstützt.

Beispielsweise wenn eine Version 01 der Packetversion ohne die Batterie aber mit vergrößertem Messbereich hinzukommt, kann einfach ein weiterer kompletter Funktionskörper hinzugefügt werden, der dank der ersten beiden Bits ohne weiteres Zutun ausgewählt wird. Das erste passende Funktionskopf wird dann ausgewählt und ausgeführt.

def parse(<<0::1, 1::1, selftest::1, meter_value::little-13>>, meta) do
  %{
    packet_version: 1,
    selftest_ok: (selftest == 1),
    meter_value: meter_value,
  }
end

Hilfsfunktionen

Durch die Zeile use Platform.Parsing.Behaviour werden folgende Hilfsfunktionen im Parser verfügbar. Diese sollen die Umsetzung von Parsers vereinfachen und weitere Funktionen zugänglich machen.

get/3

Um den Zugriff auf geschachtelte Datenstrukturen zu vereinfachen gibt es die Hilfsfunktion get/3 mit folgender Signatur:

get(meta, [:device, :name], default // nil)

Als erstes Argument erwartet get/3 eine Datenstruktur mit Schlüsseln, wie eine Map, Liste oder Tuple, welche weiter geschachtelt sein können. Als zweites Argument wird eine Liste von Atomen (wie z.B. :device) oder Integern (Offset in Listen und Tupeln) erwartet. Das dritte Argument ist optional und gibt den Standardwert (nil) an, wenn ein Schlüssel nicht gefunden werden können.

Als Besonderheit kann diese Funktion den Zugriff auf Maps vereinfachen, indem immer zuerst nach dem Schlüssel at Atom gesucht und wenn dieser nicht vorhanden ist, nach dem Schlüssel als String geprüft wird. Daher darf die Liste des zweiten Arguments nur Atome enthalten, sonst wird der Wert von default zurückgegeben.

Beispiel

data = %{device: %{name: "Device1", meta: %{"number" => 1234}, location: %{coordinates: {9.99, 53.55}}}

device_name = get(data, [:device, :name], "")
device_number = get(data, [:device, :meta, :number], 0)
device_missing = get(data, [:device, :meta, :missing], "no-value")
device_gps_lon = get(data, [:device, :location, :coordinates, 0])

# device_name will be "Device1"
# device_number will be 1234
# device_missing will be "no-value" because the key :missing was not found
# device_gps_lon will be 9.99

get_last_reading/2

Wenn ein Messwert von einem vorherigen Messwert abhängig ist, kann auf den letzte Messwert aus der Datenbank mit dieser Hilfsfunktion zugegriffen werden.

Als erstes Argument erwartet die Funktion meta, also das zweite Argument der parse(event, meta) Funktion. Ein optionales zweites Argument kann eine List mit Filtern sein, auf welche die Daten des Messwerts passen müssen.

Der Rückgabewert der Funktion ist eine Reading Datenstruktur oder nil wenn kein Messwert existiert.

{
  "parser_id": "cee40cb2-f58c-4ca8-ae91-4ae76cba5119",
  "packet_id": "7ba0beee-1ea5-4a4b-b20c-75c2706c9710",
  "measured_at": "2017-08-02T09:11:04.910127Z",
  "location": null,
  "inserted_at": "2017-08-02T09:11:04.918087Z",
  "id": "07c04413-940b-4ee6-be99-da72a0321e25",
  "device_id": "43b5dca3-e7d9-4028-9309-c26ac0c08721",
  "data": {
    "foo": 96,
    "baz": 8,
    "bar": 35,
    "status": "success",
  }
}

Als Filter sind folgende möglich:

  • [field: "value"] - Wird nach einem Messwert des Geräts suchen, bei dem in den Daten des Messwerts die Spalte field vorhanden ist den Wert "value" hat.
  • [field: :_] - Wird nach einem Messwert des Geräts suchen, bei dem in den Daten des Messwerts die Spalte field vorhanden ist und irgendeinen Wert hat.

Filter können in der Liste kombiniert werden.

Beispiele

# Fetch the last reading from database
reading = get_last_reading(meta)

# Fetch last reading where a filter matches the data
reading = get_last_reading(meta, [status: "success"])

# Fetch last reading where all filters match the data
reading = get_last_reading(meta, [status: "success", foo: 96])

# Fetch last reading where the column "bar" exists.
reading = get_last_reading(meta, [bar: :_])

json_decode/1

Diese Hilfsfunktion kann einen JSON String in eine entsprechende Datenstruktur überführen.

Das erste und einzige Argument ist der JSON String.

Der Rückgabewert ist entweder im Erfolgsfall das Tuple {:ok, data} oder im Fehlerfall {:error, error}.

Beispiele:

json = "{\"foo\":\"bar\",\"answer\":42}"
{:ok, data} = json_decode(json)
# data will be %{"answer" => 42, "foo" => "bar"}

json = "some invalid json"
{:error, error} = json_decode(json)
# error may be {:invalid, "s", 0}

json = :not_a_binary_string
{:error, error} = json_decode(json)
# error will be :invalid_input


# Example error handling
case json_decode(json) do
  {:ok, data} ->
    Map.take(data, ["value1", "value2]) # Create reading with selected keys from JSON
  {:error, error} -> 
    Logger.warn("Could not parse JSON because of: #{inspect error}")
    [] # Do not create reading
end

Hinweise

Parser Testen Funktion

Auf der Plattform selber kann der mit der "Parser Testen" Funktion der Quellcode des Parsers auf Funktionsfähigkeit geprüft werden. Dies sollte vor Inbetriebnahme auch durchgeführt werden, da sonst ggf. keine Messwerte erstellt werden.

Lokale Entwicklung

Es ist möglich den Elixir Compiler lokal auf seinem Computer zu installieren und über die mitgelieferte iex-shell den Code in gewohnter Umgebung entwickelen und testen zu können. Bei komplexen Parsern wird empfohlen, diese zusätzlich lokal zu speichern.

Komplexe Payloads

Durch die Flexibilität des Parsers können auch sehr komplexe Payloads verarbeitet werden. Bei ausreichender Spezifikation und vorhandener Testdaten kann dies auch im Auftrag durch die ZENNER IoT Solutions GmbH ausgeführt werden.