はじめに
この記事はNervesJP Advent Calendar 2021の10日目の記事です。9日目は @zacky1972 さんのErlang VMのprocess_flagのmessage_queue_dataのon_heapとoff_heapで軽く性能評価してみましたでした。
この記事はこちらを参考に
Nervesで自前で立てたGPS Logger Serverにデータを送信するGPS Logger Clientを実装していきます
準備するもの
- Raspberry Pi + AC ケーブル
- microSDカード
- GPS module(秋月電子 AE-GYSFDMAXB)
- ジャンパーワイヤ(メス-メス)
- ハンダゴテ + 半田
- GPS Logger server (動作確認用)
モジュール構成
- DataFetcher -> UART待受
- Transporter -> GPS data 送信
- NMEA(GPS Log Parser) -> GPS信号をLat,Lngに形成
プロジェクト生成
mix nerves.new gps_logger
cd gps_logger
ネットワーク設定
起動時にwifiにつなぐために設定します、LANケーブル刺す人は飛ばしておk
config :vintage_net,
regulatory_domain: "US",
config: [
...
{"wlan0", %{
type: VintageNetWiFi,
vintage_net_wifi: %{
networks: [
%{
key_mgmt: :wpa_psk,
ssid: "your wifi ssid",
psk: "your wifi password"
}
]
}
}}
]
DataFetcher
最初にデータ取得部分を実装していきます
まずUARTからデータを受信するCircuitUARTをsuperviserで起動します
またRegistryはelixirのインメモリKVSであり
via RegistryとすることでRegistryの"uart" keyを通してプログラム全体からアクセスできるようなります
def children(_target) do
[
{Registry,[keys: :unique, name: GpsLogger.Registry]},
{Circuits.UART, [name: {:via, Registry, {GpsLogger.Registry, "uart"}}]},
{GpsLogger.DataFetcher,[]}
]
end
Nervesは基本的にGenServerを起動させていく感じです
start_linkでプロセスを起動
init関数で Circuits.UARTの設定と待受を開始します
UARTからのデータ受信がの以下の形式でくるのでマッチしたら受信時の関数を実行します
{:circuits_uart, port, data}, state)
データのパースとサーバーへの通信は未実装なのでとりあえずinspect
だけしておきます
defmodule GpsLogger.DataFetcher do
use GenServer
@name __MODULE__
@doc """
Start the fetcher and open communication with GPS card.
"""
def start_link(state \\ []) do
GenServer.start_link(@name, state, name: @name)
end
@impl true
def init(_state) do
[{uart, nil}] = Registry.lookup(GpsLogger.Registry, "uart")
Circuits.UART.configure(uart, framing: {Circuits.UART.Framing.Line, separator: "\r\n"})
Circuits.UART.open(uart, "ttyAMA0", speed: 9600, active: true)
{:ok, %{current_position: nil}}
end
@impl true
def handle_info({:circuits_uart, port, data}, state) do
receive_data({:circuits_uart, port, data}, state)
end
def receive_data({:circuits_uart, _port, data}, state) do
IO.inspect(state)
{:noreply, state}
end
end
# NMEA(GPS Log Parser)
次は受信したデータを latitude, longitudeの形式にパースしていきます
いくつか信号の種類があるのでこちらを参考にして実装します
位置情報が入ってるGPGGAとGPRMCの信号をコンマでsplitしてparse_detaを実行して{time, lat, lng}を返します
ここはよくわからなかったので元コードをそのまま写しています
defmodule GpsLogger.Nmea do
def parse(data) do
data
|> String.split(",")
|> to_gps_struct()
end
defp to_gps_struct([
"$GPGGA", time, latitude, latitude_cardinal,
longitude, longitude_cardinal, _type, _nb_satellites, _percision,
_altitude,_altitude_unit, _, _, _, _sig
] = data) do
parse_data(latitude, latitude_cardinal, longitude, longitude_cardinal, time, data)
end
defp to_gps_struct([
"$GPRMC", time, _data_state, latitude, latitude_cardinal,
longitude, longitude_cardinal, _speed, _, _, _, _, _sig
] = data) do
parse_data(latitude, latitude_cardinal, longitude, longitude_cardinal, time, data)
end
defp to_gps_struct(data) do
{:error, %{message: "can't parse data", data: Enum.join(data, ",")}}
end
defp parse_data(lat, lat_cardinal, lng, lng_cardinal, time, data) do
with {:ok, latitude} <- to_degres("#{lat},#{lat_cardinal}"),
{:ok, longitude} <- to_degres("#{lng},#{lng_cardinal}") do
{:ok, %{time: time, lat: latitude, lng: longitude}}
else
{:error, %{message: "empty data"}} ->
{:error, %{message: "empty data", data: Enum.join(data, ",")}}
_ ->
{:error, %{message: "can't parse data", data: Enum.join(data, ",")}}
end
end
def to_degres(
<<degres::bytes-size(2)>> <>
<<minutes::bytes-size(7)>> <>
<<_sep::bytes-size(1)>> <>
<<cardinal::bytes-size(1)>>
) do
{:ok, do_to_degres(degres, minutes, cardinal)}
end
def to_degres(
<<degres::bytes-size(3)>> <>
<<minutes::bytes-size(6)>> <>
<<_sep::bytes-size(1)>> <>
<<cardinal::bytes-size(1)>>
) do
{:ok, do_to_degres(degres, minutes, cardinal)}
end
def to_degres(
<<degres::bytes-size(3)>> <>
<<minutes::bytes-size(7)>> <>
<<_sep::bytes-size(1)>> <>
<<cardinal::bytes-size(1)>>
) do
{:ok, do_to_degres(degres, minutes, cardinal)}
end
def to_degres(",") do
{:error, %{message: "empty data"}}
end
defp do_to_degres(degres, minutes, cardinal) do
degres = degres |> float_parse()
minutes = minutes |> float_parse()
(degres + minutes / 60) |> Float.round(5) |> with_cardinal_orientation(cardinal)
end
defp with_cardinal_orientation(degres, cardinal) when cardinal in ["N", "E"] do
degres
end
defp with_cardinal_orientation(degres, cardinal) when cardinal in ["S", "W"] do
-degres
end
defp float_parse(value) do
{value_parsed, _} = Float.parse(value)
value_parsed
end
end
Transpondeur
Lat,Lngを受け取って位置情報が更新されているかをチェックして送信先URL(endpoint)にpostするモジュールです
defmodule GpsLogger.Application do
...
def children(_target) do
[
{Registry,[keys: :unique, name: GpsLogger.Registry]},
{Circuits.UART, [name: {:via, Registry, {GpsLogger.Registry, "uart"}}]},
{GpsLogger.Transpondeur, ["送信先local net ip:4000/api/points"]}, # <- 追加
{GpsLogger.DataFetcher,[]}
]
end
...
end
childrenで指定した送信先URLがstart_linkのendpointにはいります
初回は
handle_cast({:emit, position}, state = %{endpoint: endpoint, current_position: nil})
が呼ばれ
endpointにHTTPoison.postします
2回目移行は
handle_cast({:emit, position}, state = %{endpoint: endpoint, current_position: current_position})
が呼ばれ
GpsLogger.Distanceで更新前の位置との差分を取って5m以上移動していたら HTTPoision.postします
defmodule GpsLogger.Transpondeur do
use GenServer
@name __MODULE__
def start_link(endpoint) do
GenServer.start_link(@name, endpoint, name: @name)
end
def emit(coordinates) do
GenServer.cast(@name, {:emit, coordinates})
end
@impl true
def init(endpoint) do
{:ok, %{endpoint: endpoint, current_position: nil}}
end
@impl true
def handle_cast({:emit, position}, state = %{endpoint: endpoint, current_position: nil}) do
post_to(endpoint, position)
{:noreply, %{state | current_position: position}}
end
def handle_cast({:emit, position}, state = %{endpoint: endpoint, current_position: current_position}) do
with true <- position_issued_after?(position, current_position),
{:ok, distance} <- GpsLogger.Distance.compute(position, current_position),
{:ok, distance_in_meters} <- GpsLogger.Distance.to_meters(distance),
true <- distance_in_meters > 5.0
do
post_to(endpoint, position)
{:noreply, %{state | current_position: position }}
else
_ ->
{:noreply, state}
end
end
def position_issued_after?(position, current_position) do
with {position_time, ""} <- Float.parse(Map.get(position, :time, "0")),
{current_position_time, ""} <- Float.parse(Map.get(current_position, :time, "0"))
do
position_time > current_position_time
end
end
defp post_to(endpoint, position) do
{:ok, json} = position |> Jason.encode()
HTTPoison.post(endpoint, json, %{"Content-Type": "application/json"})
end
end
defmodule GpsLogger.Distance do
def compute(%{longitude: x1, latitude: y1}, %{longitude: x2, latitude: y2}) do
distance =
(:math.pow(x1 - x2, 2) + :math.pow(y1 - y2, 2))
|> :math.sqrt()
|> Float.ceil(5)
{:ok, distance}
end
def compute(_pos1, _pos2), do: :error
def to_meters(distance) do
distance_in_meters = (distance * 111_319.0) |> Float.ceil(5)
{:ok, distance_in_meters}
end
end
データ送信部分ができたのでデータ取得時にGpsLogger.Transpondeur.emitでサーバーにデータ送信を実行するようにします
defmodule GpsLogger.DataFetcher do
...
def receive_data({:circuits_uart, _port, data}, state) do
state =
case GpsLogger.Nmea.parse(data) do
{:ok, position} ->
GpsLogger.Transpondeur.emit(position)
%{current_position: position}
_ ->
state
end
{:noreply, state}
end
end
コードができたので実際にSDカードに焼きましょう
export MIX_TARGET=rpi0
mix firmware
mix firmware.burn !
データ解析部分が少し複雑ですが、それ以外は基本的なGenServerなアプリケーションになったかと思います
認証周りも含めたコードがこちらになります
https://github.com/thehaigo/live_map_nerves
明日は @nishiuchikazuma さんの Nervesにユーザ名/パスワードでSSHログインする です
参考ページ
https://hexdocs.pm/nerves/installation.html#content
https://hexdocs.pm/circuits_uart/readme.html
https://hexdocs.pm/httpoison/HTTPoison.html
https://github.com/yannvery/gps_tracker
https://qiita.com/mnishiguchi/items/f4668697cb371ea6bb39
https://qiita.com/sand/items/5fd91c0b86b4919d7cc9
https://qiita.com/takasehideki/items/e7cc1a2d0a4a7140c1cf
https://qiita.com/kentaro/items/e8df79aa93b9fe9a567e
https://piyajk.com/archives/tag/gpgsa
https://blog.tubone-project24.xyz/2020/1/24/elixir-loadtest
https://akizukidenshi.com/catalog/g/gK-09991/