Current section

Files

Jump to
astarte_device lib astarte_device.ex
Raw

lib/astarte_device.ex

#
# This file is part of Astarte.
#
# Copyright 2019 Ispirata Srl
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
defmodule Astarte.Device do
@moduledoc """
A process that manages a device connection to Astarte. See `Astarte.Device.start_link/1` for the options.
"""
@behaviour :gen_statem
require Logger
alias Astarte.Device.Impl
defmodule Data do
@moduledoc false
@type t :: %Astarte.Device.Data{
pairing_url: String.t(),
realm: String.t(),
device_id: Astarte.Core.Device.encoded_device_id(),
client_id: String.t(),
credentials_secret: String.t(),
ignore_ssl_errors: boolean(),
credential_storage_mod: module(),
credential_storage_state: term(),
interface_provider_mod: module(),
interface_provider_state: term(),
handler_pid: pid(),
waiting_for_connection: [pid()],
broker_url: String.t() | nil,
mqtt_connection: pid() | nil
}
@enforce_keys [
:pairing_url,
:realm,
:device_id,
:client_id,
:credentials_secret,
:ignore_ssl_errors,
:max_http_redirects,
:credential_storage_mod,
:credential_storage_state,
:interface_provider_mod,
:interface_provider_state,
:handler_pid,
:waiting_for_connection
]
defstruct [
:broker_url,
:mqtt_connection
| @enforce_keys
]
def from_opts!(opts) do
pairing_url = Keyword.fetch!(opts, :pairing_url)
realm = Keyword.fetch!(opts, :realm)
device_id = Keyword.fetch!(opts, :device_id)
client_id = Keyword.fetch!(opts, :client_id)
credentials_secret = Keyword.fetch!(opts, :credentials_secret)
ignore_ssl_errors = Keyword.fetch!(opts, :ignore_ssl_errors)
max_http_redirects = Keyword.fetch!(opts, :max_http_redirects)
credential_storage_mod = Keyword.fetch!(opts, :credential_storage_mod)
credential_storage_state = Keyword.fetch!(opts, :credential_storage_state)
interface_provider_mod = Keyword.fetch!(opts, :interface_provider_mod)
interface_provider_state = Keyword.fetch!(opts, :interface_provider_state)
handler_pid = Keyword.fetch!(opts, :handler_pid)
%Data{
pairing_url: pairing_url,
realm: realm,
device_id: device_id,
client_id: client_id,
credentials_secret: credentials_secret,
ignore_ssl_errors: ignore_ssl_errors,
max_http_redirects: max_http_redirects,
credential_storage_mod: credential_storage_mod,
credential_storage_state: credential_storage_state,
interface_provider_mod: interface_provider_mod,
interface_provider_state: interface_provider_state,
handler_pid: handler_pid,
waiting_for_connection: []
}
end
end
# API
@type device_options :: [device_option]
@type device_option ::
{:pairing_url, String.t()}
| {:realm, String.t()}
| {:device_id, String.t()}
| {:credentials_secret, String.t()}
| {:credential_storage, {module(), term()}}
| {:interface_provider, {module(), term()} | String.t()}
| {:handler, {module(), term()}}
| {:ignore_ssl_errors, boolean()}
@max_http_redirects_default 5
@doc """
Returns a specification to start this module under a supervisor.
See `Supervisor` in Elixir v1.6+.
"""
def child_spec(arg) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [arg]}
}
end
@doc """
Start an `Astarte.Device`.
## Device Options
* `pairing_url` - base URL of the Astarte Pairing API instance the device will connect to, e.g. `https://astarte.api.example.com/pairing` or `http://localhost:4003` for a local installation.
* `realm` - Realm which the device belongs to.
* `device_id` - Device ID of the device. The device ID must be 128-bit long and must be encoded with url-safe base64 without padding. You can generate a random one with `:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)`.
* `credentials_secret` - The credentials secret obtained when registering the device using Pairing API (to register a device use `Astarte.API.Pairing.Agent.register_device/2` or see https://docs.astarte-platform.org/latest/api/index.html?urls.primaryName=Pairing%20API#/agent/registerDevice).
* `interface_provider` - A tuple `{module, args}` where `module` is a module implementing `Astarte.Device.InterfaceProvider` behaviour and `args` are the arguments passed to its init function. It's also possible to pass a path containing the JSON interfaces the device will use, and that path will be passed to `Astarte.Device.FilesystemInterfaceProvider`.
* `credential_storage` (optional) - A tuple `{module, args}` where `module` is a module implementing `Astarte.Device.CredentialStorage` behaviour and `args` are the arguments passed to its init function. If not provided, `Astarte.Device.InMemoryStorage` will be used.
* `handler` (optional) - A tuple `{module, args}` where `module` is a module implementing `Astarte.Device.Handler` behaviour and `args` are the arguments passed to its `init_state` function. If not provided, `Astarte.Device.DefaultHandler` will be used.
* `ignore_ssl_errors` (optional) - Defaults to `false`, if `true` the device will ignore SSL errors during connection. Useful if you're using the Device to connect to a test instance of Astarte with self signed certificates, it is not recommended to leave this `true` in production.
* `max_http_redirects` (optional) - The maximum number of HTTP redirects that will be followed by the Pairing HTTP client.
Defaults to #{@max_http_redirects_default}.
"""
@spec start_link(opts :: device_options()) :: :gen_statem.start_ret()
def start_link(device_options) do
pairing_url = Keyword.fetch!(device_options, :pairing_url)
realm = Keyword.fetch!(device_options, :realm)
device_id = Keyword.fetch!(device_options, :device_id)
client_id = "#{realm}/#{device_id}"
credentials_secret = Keyword.fetch!(device_options, :credentials_secret)
ignore_ssl_errors = Keyword.get(device_options, :ignore_ssl_errors, false)
max_http_redirects =
Keyword.get(device_options, :max_http_redirects, @max_http_redirects_default)
{credential_storage_mod, credential_storage_args} =
Keyword.get(device_options, :credential_storage, {Astarte.Device.InMemoryStorage, []})
{interface_provider_mod, interface_provider_args} =
case Keyword.fetch!(device_options, :interface_provider) do
{mod, args} when is_atom(mod) ->
{mod, args}
path when is_binary(path) ->
{Astarte.Device.FilesystemInterfaceProvider, path: path}
end
{handler_mod, handler_args} =
Keyword.get(device_options, :handler, {Astarte.Device.DefaultHandler, []})
with {:device_id, {:ok, _decoded_device_id}} <-
{:device_id, Astarte.Core.Device.decode_device_id(device_id)},
{:cred, {:ok, credential_storage_state}} <-
{:cred, credential_storage_mod.init(credential_storage_args)},
{:interface, {:ok, interface_provider_state}} <-
{:interface, interface_provider_mod.init(interface_provider_args)} do
opts = [
pairing_url: pairing_url,
realm: realm,
device_id: device_id,
client_id: client_id,
credentials_secret: credentials_secret,
ignore_ssl_errors: ignore_ssl_errors,
max_http_redirects: max_http_redirects,
credential_storage_mod: credential_storage_mod,
credential_storage_state: credential_storage_state,
interface_provider_mod: interface_provider_mod,
interface_provider_state: interface_provider_state,
handler_mod: handler_mod,
handler_args: handler_args
]
:gen_statem.start_link(via_tuple(realm, device_id), __MODULE__, opts, [])
else
{:device_id, _} ->
_ = Logger.warn("#{client_id}: Invalid device_id: #{device_id}")
{:error, :invalid_device_id}
{:cred, {:error, reason}} ->
_ =
Logger.warn(
"#{client_id}: Can't initialize CredentialStorage for #{client_id}: #{inspect(reason)}"
)
{:error, :credential_storage_failed}
{:interface, {:error, reason}} ->
_ =
Logger.warn(
"#{client_id}: Can't initialize InterfaceProvider for #{client_id}: #{inspect(reason)}"
)
{:error, :interface_provider_failed}
end
end
@doc """
Returns the `pid` of the `Astarte.Device` process for the given `realm/device_id` pair, or `nil` if
there's no existing device for that pair.
Devices are registered to `Astarte.Device.Registry` with key `{realm, device_id}` when they are started.
"""
@spec get_pid(realm :: String.t(), device_id :: Astarte.Core.Device.encoded_device_id()) ::
pid() | nil
def get_pid(realm, device_id) do
case Registry.lookup(Astarte.Device.Registry, {realm, device_id}) do
[{pid, _}] ->
pid
_ ->
nil
end
end
@spec via_tuple(realm :: String.t(), device_id :: Astarte.Core.Device.encoded_device_id()) ::
{:via, registry :: module(), via_name :: term()}
defp via_tuple(realm, device_id) do
{:via, Registry, {Astarte.Device.Registry, {realm, device_id}}}
end
@doc """
Send a datastream value to Astarte.
This call is blocking and waits for the message to be ACKed at the MQTT level.
"""
@spec send_datastream(
pid :: pid(),
interface_name :: String.t(),
path :: String.t(),
value :: term(),
opts :: options
) ::
:ok
| {:error, reason :: term()}
when options: [option],
option: {:qos, qos :: Tortoise.qos()} | {:timestamp, timestamp :: DateTime.t()}
def send_datastream(pid, interface_name, path, value, opts \\ []) do
:gen_statem.call(pid, {:send_datastream, interface_name, path, value, opts})
end
@doc """
Send a property value to Astarte.
This call is blocking and waits for the message to be ACKed at the MQTT level.
"""
@spec set_property(
pid :: pid(),
interface_name :: String.t(),
path :: String.t(),
value :: term()
) ::
:ok
| {:error, reason :: term()}
def set_property(pid, interface_name, path, value) do
:gen_statem.call(pid, {:set_property, interface_name, path, value})
end
@doc """
Unset a property value to Astarte.
This call is blocking and waits for the message to be ACKed at the MQTT level.
"""
@spec unset_property(
pid :: pid(),
interface_name :: String.t(),
path :: String.t()
) ::
:ok
| {:error, reason :: term()}
def unset_property(pid, interface_name, path) do
:gen_statem.call(pid, {:unset_property, interface_name, path})
end
@doc """
Blocks until the device succesfully connects to the broker, then returns `:ok`.
Useful to ensure the device has established the initial connection before beginning
to publish.
"""
@spec wait_for_connection(pid :: pid()) :: :ok
def wait_for_connection(pid) do
:gen_statem.call(pid, :wait_for_connection)
end
# Callbacks
@impl true
def callback_mode, do: :state_functions
@impl true
def init(opts) do
realm = Keyword.fetch!(opts, :realm)
device_id = Keyword.fetch!(opts, :device_id)
handler_mod = Keyword.fetch!(opts, :handler_mod)
handler_args = Keyword.fetch!(opts, :handler_args)
handler_full_args = [
realm: realm,
device_id: device_id,
user_args: handler_args
]
# TODO: this should probably go in a supervision tree with the Device,
# avoiding the need to peek in the options
{:ok, handler_pid} = handler_mod.start_link(handler_full_args)
new_opts = Keyword.put(opts, :handler_pid, handler_pid)
case Impl.init(new_opts) do
{:ok, new_data} ->
actions = [{:next_event, :internal, :request_info}]
{:ok, :waiting_for_info, new_data, actions}
{:no_keypair, new_data} ->
actions = [{:next_event, :internal, :generate_keypair}]
{:ok, :no_keypair, new_data, actions}
{:no_certificate, new_data} ->
actions = [{:next_event, :internal, :request_certificate}]
{:ok, :no_certificate, new_data, actions}
end
end
def no_keypair(:internal, :generate_keypair, data) do
case Impl.generate_keypair(data) do
{:ok, new_data} ->
actions = [{:next_event, :internal, :request_certificate}]
{:next_state, :no_certificate, new_data, actions}
{:error, reason} ->
# TODO: handle transient errors, for now we stop if a keypair
# can't be generated or saved
{:stop, reason}
end
end
def no_keypair({:call, from}, :wait_for_connection, data) do
add_waiting_for_connection(data, from)
end
def no_keypair({:call, from}, _request, _data) do
handle_disconnected_publish(from)
end
def no_certificate(:internal, :request_certificate, data) do
case Impl.request_certificate(data) do
{:ok, new_data} ->
actions = [{:next_event, :internal, :request_info}]
{:next_state, :waiting_for_info, new_data, actions}
{:error, :temporary} ->
# TODO: exponential backoff
actions = [{:state_timeout, 30_000, :retry_request_certificate}]
_ = Logger.warn("Trying again in 30 seconds")
{:keep_state_and_data, actions}
{:error, reason} ->
{:stop, reason}
end
end
def no_certificate(:state_timeout, :retry_request_certificate, _data) do
actions = [{:next_event, :internal, :request_certificate}]
{:keep_state_and_data, actions}
end
def no_certificate({:call, from}, :wait_for_connection, data) do
add_waiting_for_connection(data, from)
end
def no_certificate({:call, from}, _request, _data) do
handle_disconnected_publish(from)
end
def waiting_for_info(:internal, :request_info, data) do
case Impl.request_info(data) do
{:ok, new_data} ->
actions = [{:next_event, :internal, :connect}]
{:next_state, :disconnected, new_data, actions}
{:error, :temporary} ->
# TODO: exponential backoff
actions = [{:state_timeout, 30_000, :retry_request_info}]
_ = Logger.warn("Trying again in 30 seconds")
{:keep_state_and_data, actions}
{:error, reason} ->
{:stop, reason}
end
end
def waiting_for_info(:state_timeout, :retry_request_info, _data) do
actions = [{:next_event, :internal, :request_info}]
{:keep_state_and_data, actions}
end
def waiting_for_info({:call, from}, :wait_for_connection, data) do
add_waiting_for_connection(data, from)
end
def waiting_for_info({:call, from}, _request, _data) do
handle_disconnected_publish(from)
end
def disconnected(:internal, :connect, data) do
case Impl.connect(data) do
{:ok, new_data} ->
{:next_state, :connecting, new_data}
{:error, reason} ->
# The connection has its own retry mechanism, if we're here the error is fatal
{:stop, reason}
end
end
def disconnected(:state_timeout, :retry_connect, _data) do
actions = [{:next_event, :internal, :connect}]
{:keep_state_and_data, actions}
end
def disconnected({:call, from}, :wait_for_connection, data) do
add_waiting_for_connection(data, from)
end
def disconnected({:call, from}, _request, _data) do
handle_disconnected_publish(from)
end
def connecting(:cast, {:connection_status, :up}, %Data{client_id: client_id} = data) do
_ = Logger.info("#{client_id}: Connected")
%Data{
waiting_for_connection: waiting
} = data
# Reply to all clients waiting for connection
connection_reply_actions =
for ref <- waiting do
{:reply, ref, :ok}
end
# TODO: we always send empty cache and producer properties for now since we can't access the session_present flag
actions = [
{:next_event, :internal, :send_introspection},
{:next_event, :internal, :send_empty_cache},
{:next_event, :internal, :send_producer_properties}
| connection_reply_actions
]
new_data = %{data | waiting_for_connection: []}
{:next_state, :connected, new_data, actions}
end
def connecting({:call, from}, :wait_for_connection, data) do
add_waiting_for_connection(data, from)
end
def connecting({:call, from}, _request, _data) do
handle_disconnected_publish(from)
end
def connected(:internal, :send_introspection, data) do
# TODO: handle errors
:ok = Impl.send_introspection(data)
:keep_state_and_data
end
def connected(:internal, :send_empty_cache, data) do
# TODO: handle errors
:ok = Impl.send_empty_cache(data)
:keep_state_and_data
end
def connected(:internal, :send_producer_properties, data) do
# TODO: handle errors
:ok = Impl.send_producer_properties(data)
:keep_state_and_data
end
def connected(:cast, {:connection_status, :down}, %Data{client_id: client_id} = data) do
# Tortoise will reconnect for us, just go to the :connecting state
_ = Logger.info("#{client_id}: Disconnected. Retrying connection...")
{:next_state, :connecting, data}
end
def connected(:cast, {:msg, topic_tokens, payload}, data) do
%Data{
client_id: client_id
} = data
case Impl.handle_message(topic_tokens, payload, data) do
:ok ->
:keep_state_and_data
{:ok, new_data} ->
{:keep_state, new_data}
{:error, reason} ->
Logger.warn("#{client_id}: error in handle_message #{inspect(reason)}")
:keep_state_and_data
end
end
def connected({:call, from}, :wait_for_connection, _data) do
actions = [{:reply, from, :ok}]
{:keep_state_and_data, actions}
end
def connected({:call, from}, {:send_datastream, interface_name, path, value, opts}, data) do
publish_params = [
publish_type: :datastream,
interface_name: interface_name,
path: path,
value: value,
opts: opts
]
reply = Impl.publish(publish_params, data)
actions = [{:reply, from, reply}]
{:keep_state_and_data, actions}
end
def connected({:call, from}, {:set_property, interface_name, path, value}, data) do
publish_params = [
publish_type: :properties,
interface_name: interface_name,
path: path,
value: value,
opts: [qos: 2]
]
reply = Impl.publish(publish_params, data)
actions = [{:reply, from, reply}]
{:keep_state_and_data, actions}
end
def connected({:call, from}, {:unset_property, interface_name, path}, data) do
publish_params = [
publish_type: :properties,
interface_name: interface_name,
path: path,
value: nil,
opts: [qos: 2]
]
reply = Impl.publish(publish_params, data)
actions = [{:reply, from, reply}]
{:keep_state_and_data, actions}
end
def connected(_event_type, _event, _data) do
:keep_state_and_data
end
defp add_waiting_for_connection(%Data{waiting_for_connection: waiting} = data, from) do
new_data = %{data | waiting_for_connection: [from | waiting]}
{:keep_state, new_data}
end
defp handle_disconnected_publish(from) do
actions = [{:reply, from, {:error, :device_disconnected}}]
{:keep_state_and_data, actions}
end
end