├── .gitignore ├── README.md ├── config └── config.exs ├── lib └── aws │ ├── iot.ex │ └── iot │ ├── thing_shadow.ex │ └── thing_shadow │ ├── client.ex │ ├── event_handler.ex │ └── supervisor.ex ├── mix.exs └── test ├── aws_iot_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS IoT SDK for Elixir 2 | 3 | The aws-iot-device-sdk-elixir package allows developers to write [Elixir](http://elixir-lang.org) applications which access the [AWS IoT Platform](https://aws.amazon.com/iot/) via [MQTT](http://docs.aws.amazon.com/iot/latest/developerguide/protocols.html). It can be used in standard [Elixir](http://elixir-lang.org) environments as well as in [Nerves](http://nerves-project.org) embedded applications. 4 | 5 | Closely follows the logic from the official nodejs library, with minor deviations to adapt to Elixir Coding Conventions: 6 | https://github.com/aws/aws-iot-device-sdk-js/blob/master/thing/index.js 7 | 8 | * [Overview](#overview) 9 | * [Installation](#install) 10 | * [Configuration](#configuration) 11 | * [Basic Usage](#basic-usage) 12 | * [Application Usage](#otp-usage) 13 | * [Troubleshooting](#troubleshooting) 14 | * [License](#license) 15 | * [Support](#support) 16 | 17 | 18 | 19 | ## Overview 20 | This document provides instructions on how to install and configure the AWS 21 | IoT device SDK for Elixir, and includes examples demonstrating use of the 22 | SDK APIs. 23 | 24 | #### MQTT Connection 25 | This package is built on top of [emqttc](https://github.com/emqtt/emqttc) and provides two modules: 'Aws.Iot.Device' 26 | and 'Aws.Iot.ThingShadow'. The 'Device' module wraps [emqttc](https://github.com/emqtt/emqttc) to provide a 27 | secure connection to the AWS IoT platform and expose the [emqttc](https://github.com/emqtt/emqttc) API upward. It provides features to simplify handling of intermittent connections, including progressive backoff retries, automatic re-subscription upon connection, and queued offline publishing. 28 | 29 | #### Thing Shadows 30 | The 'Aws.Iot.ThingShadow' module implements additional functionality for accessing Thing Shadows via the AWS IoT 31 | API; the ThingShadow module allows devices to update, be notified of changes to, 32 | get the current state of, or delete Thing Shadows from AWS IoT. Thing 33 | Shadows allow applications and devices to synchronize their state on the AWS IoT platform. 34 | For example, a remote device can update its Thing Shadow in AWS IoT, allowing 35 | a user to view the device's last reported state via a mobile app. The user 36 | can also update the device's Thing Shadow in AWS IoT and the remote device 37 | will synchronize with the new state. The 'ThingShadow' module supports multiple 38 | Thing Shadows per mqtt connection and allows pass-through of non-Thing-Shadow 39 | topics and mqtt events. 40 | 41 | 42 | 43 | ## Installation 44 | 45 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 46 | 47 | 1. Add aws_iot to your list of dependencies in `mix.exs`: 48 | 49 | def deps do 50 | [{:aws_iot, "~> 0.0.1"}] 51 | end 52 | 53 | 2. Ensure aws_iot is started before your application: 54 | 55 | def application do 56 | [applications: [:aws_iot]] 57 | end 58 | 59 | 60 | 61 | ## Configuration 62 | 63 | The Mix configuration in config/config.exs should look like this: 64 | 65 | ```elixir 66 | config :aws_iot, Aws.Iot.ThingShadow.Client, 67 | host: "xxxxxxxxx.iot..amazonaws.com", 68 | port: 8883, 69 | client_id: "xxxxxxx", 70 | ca_cert: "config/certs/root-CA.crt", 71 | client_cert: "config/certs/xxxxxxxxxx-certificate.pem.crt", 72 | private_key: "config/certs/xxxxxxxxxx-private.pem.key", 73 | mqttc_opts: [] 74 | ``` 75 | 76 | 77 | 78 | ## Basic Usage 79 | 80 | You can try out the below in IEx: 81 | 82 | ```elixir 83 | alias Aws.Iot.ThingShadow 84 | 85 | # Initializes a new ThingShadow.Client process 86 | # Events from this client will be forwarded to self() 87 | {:ok, client} = ThingShadow.init_client([cast: self()]) 88 | 89 | # Register interest in a thing (required) 90 | ThingShadow.Client.register(client, "aws-iot-thing-name") 91 | 92 | # Get shadow state for a thing 93 | ThingShadow.Client.get(client, "aws-iot-thing-name") 94 | 95 | # See ThingShadow.Client events sent to self() via MyThing.PubSub 96 | Process.info(self)[:messages] 97 | ``` 98 | 99 | 100 | 101 | ## OTP Application Usage 102 | 103 | 1. Add event bus to the supervisor of your application module. 104 | 105 | ```elixir 106 | def start(_type, _args) do 107 | ... 108 | children = [ 109 | ..., 110 | supervisor(Phoenix.PubSub.PG2, [MyThings.PubSub, []]) 111 | ] 112 | ... 113 | end 114 | ``` 115 | 116 | 2. Create a GenServer module that will subscribe to the event bus. 117 | 118 | ```elixir 119 | alias Aws.Iot.ThingShadow 120 | 121 | def init(_args) do 122 | Phoenix.PubSub.subscribe(MyThings.PubSub, "thing_shadow_event") 123 | 124 | {:ok, client} = ThingShadow.init_client([ 125 | broadcast: {MyThings.PubSub, "thing_shadow_event"} 126 | ]) 127 | 128 | # If we are only reporting state from an IoT Device, 129 | # then versioning and persistent_subscribe is not required. 130 | thing_opts = [qos: 1, enable_versioning: false, persistent_subscribe: false] 131 | ThingShadow.Client.register(client, "aws-iot-thing-name", thing_opts) 132 | 133 | {:ok, %{iot_client: client, client_token_op: %{}}} 134 | end 135 | ``` 136 | 137 | 3. Add `GenServer.handle_info/2` callbacks to GenServer for event handling. 138 | 139 | ```elixir 140 | def handle_info({:connect, client}, state) do 141 | # Note: ThingShadow.Client will handle resubscribe to topics on reconnection. 142 | 143 | # According to AWS docs, an IoT device should report 144 | # its shadow_state on reconnection 145 | shadow_state_report = %{state: %{reported: %{ 146 | "led_on" => false 147 | }}} 148 | {:ok, client_token} = ThingShadow.Client.update(client, 149 | "aws-iot-thing-name", shadow_state_report) 150 | state = put_client_token(client_token, :update, state) 151 | 152 | {:noreply, state} 153 | end 154 | 155 | def handle_info({:delta, "aws-iot-thing-name", shadow_state}, state) do 156 | IO.inspect(shadow_state) 157 | 158 | # According to AWS docs, an IoT device should act on any delta 159 | desired_state_of_led_on = shadow_state["state"]["led_on"] 160 | case desired_state_of_led_on do 161 | true -> IO.puts "Led will be turned on" 162 | false -> IO.puts "Led will be turned off" 163 | end 164 | 165 | {:noreply, state} 166 | end 167 | 168 | def handle_info({:status, "aws-iot-thing-name", :accepted, client_token, shadow_state}, state = %{client_token_op: client_token_op}) do 169 | case client_token_op[client_token] do 170 | :get -> IO.inspect(shadow_state) 171 | :update -> IO.inspect(shadow_state) 172 | :delete -> IO.inspect(shadow_state) 173 | nil -> IO.puts "Missing token" 174 | end 175 | 176 | state = remove_client_token(client_token, state) 177 | {:noreply, state} 178 | end 179 | 180 | def handle_info({:status, "aws-iot-thing-name", :rejected, client_token, shadow_state}, state) do 181 | IO.inspect(shadow_state) 182 | state = remove_client_token(client_token, state) 183 | {:noreply, state} 184 | end 185 | 186 | def handle_info({:timeout, "aws-iot-thing-name", client_token}, state) do 187 | IO.inspect(client_token) 188 | state = remove_client_token(client_token, state) 189 | {:noreply, state} 190 | end 191 | 192 | defp put_client_token(client_token, op, state = %{client_token_op: client_token_op}) do 193 | client_token_op = Map.put(client_token_op, client_token, op) 194 | %{state | client_token_op: client_token_op} 195 | end 196 | 197 | defp remove_client_token(client_token, state = %{client_token_op: client_token_op}) do 198 | client_token_op = Map.delete(client_token_op, client_token) 199 | %{state | client_token_op: client_token_op} 200 | end 201 | ``` 202 | 203 | 4. Use `ThingShadow.Client.publish/3` to trigger AWS IoT Rules. 204 | 205 | Try creating a simple rule to forward messages to AWS SNS, which sends alert notifications via Email or Cellular SMS Messages. 206 | 207 | Read more: 208 | - [AWS IoT Rules](http://docs.aws.amazon.com/iot/latest/developerguide/iot-rules.html) 209 | - [AWS IoT Rule Tutorials](http://docs.aws.amazon.com/iot/latest/developerguide/iot-rules-tutorial.html) 210 | - [AWS SNS Sending SMS](http://docs.aws.amazon.com/sns/latest/dg/SMSMessages.html) 211 | 212 | **Important**: Do not use the publish function to communicate with any IoT device, as MQTT messages are lost when the device is disconnected from the broker. 213 | 214 | 215 | 216 | ## Troubleshooting 217 | 218 | If you have problems connecting to the AWS IoT Platform when using this SDK, there are a few things to check: 219 | 220 | * _Region Mismatch_: If you didn't create your 221 | certificate in the default region ('us-east-1'), you'll need to specify 222 | the region (e.g., 'us-west-2') that you created your certificate in. 223 | * _Duplicate Client IDs_: Within your AWS account, the AWS IoT platform 224 | will only allow one connection per client ID. 225 | If you are using a [Mix configuration file](#configuration), you'll 226 | need to explictly specify client IDs. 227 | 228 | 229 | 230 | ## License 231 | 232 | This SDK is distributed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0), see LICENSE.txt and NOTICE.txt for more information. 233 | 234 | 235 | 236 | ## Support 237 | If you have technical questions about AWS IoT Device SDK, use the [Slack Nerves Channel](https://elixir-lang.slack.com/archives/nerves). 238 | For any other questions on AWS IoT, contact [AWS Support](https://aws.amazon.com/contact-us). -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :aws_iot, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:aws_iot, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/aws/iot.ex: -------------------------------------------------------------------------------- 1 | defmodule Aws.Iot do 2 | use Application 3 | 4 | @root_supervisor Aws.Iot.Supervisor 5 | 6 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 7 | # for more information on OTP Applications 8 | def start(_type, _args) do 9 | import Supervisor.Spec, warn: false 10 | 11 | children = [ 12 | # Define workers and child supervisors to be supervised 13 | # worker(AwsIot.Worker, [arg1, arg2, arg3]), 14 | #supervisor(Aws.Iot.ThingShadow.Supervisor, []) 15 | ] 16 | 17 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 18 | # for other strategies and supported options 19 | opts = [strategy: :one_for_one, name: @root_supervisor] 20 | Supervisor.start_link(children, opts) 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/aws/iot/thing_shadow.ex: -------------------------------------------------------------------------------- 1 | defmodule Aws.Iot.ThingShadow do 2 | 3 | @moduledoc ~S""" 4 | This module helps to make it easier to initialize a new `Aws.Iot.ThingShadow.Client` which: 5 | - Is supervised for automatic recovery 6 | - Has a GenEvent.manager that can survive the lifetime of the client process. 7 | - Has a GenEvent.handler that forwards event messages to other processes without blocking. 8 | 9 | `Aws.Iot.ThingShadow.Client` implements the AWS IoT ThingShadow Data Flow behaviour. 10 | It closely follows the logic from the official nodejs library, with minor deviations to adapt to Elixir Coding Conventions: 11 | https://github.com/aws/aws-iot-device-sdk-js/blob/master/thing/index.js 12 | """ 13 | 14 | @root_supervisor Aws.Iot.Supervisor 15 | 16 | @doc """ 17 | Intitializes a new `ThingShadow.Client` service, adds an event handler, and returns the client. 18 | 19 | The event handler will forward events to the processes specified in `event_handler_args`. 20 | `event_handler_args` input should be a `Keyword` list containing one or more of the below: 21 | - `{:broadcast, {Phoenix.PubSub.server, topic}}` 22 | - `{:cast, GenServer.server}` 23 | - `{:call, GenServer.server}` 24 | 25 | The calling process will receive the message `{:gen_event_EXIT, handler, reason}` if the event handler is later deleted. 26 | For more information see `GenEvent.add_mon_handler/3`. 27 | 28 | ## Return values 29 | 30 | If successful, returns {:ok, thingshadow_client}. 31 | Use the `Aws.Iot.ThingShadow.Client` module to operate on the thingshadow_client. 32 | """ 33 | @spec init_client([{:broadcast, {GenServer.server, binary}} | {:cast, GenServer.server} | {:call, GenServer.server}], GenServer.name, atom) :: {:ok, GenServer.server} | {:error, term} 34 | def init_client(event_handler_args, client_name \\ Aws.Iot.ThingShadow, app_config \\ :aws_iot) do 35 | import Supervisor.Spec, warn: false 36 | 37 | with {:ok, _client_supervisor} <- Supervisor.start_child(@root_supervisor, supervisor(Aws.Iot.ThingShadow.Supervisor, [client_name, [app_config: app_config]])), 38 | {:ok, client_event_manager} <- Aws.Iot.ThingShadow.Client.fetch_event_manager(client_name), 39 | :ok <- GenEvent.add_mon_handler(client_event_manager, Aws.Iot.ThingShadow.EventHandler, event_handler_args), 40 | do: {:ok, client_name} 41 | end 42 | 43 | end -------------------------------------------------------------------------------- /lib/aws/iot/thing_shadow/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Aws.Iot.ThingShadow.Client do 2 | 3 | @moduledoc ~S""" 4 | Implements the AWS IoT ThingShadow Data Flow behaviour. 5 | Uses the `:emqttc` erlang module for the underlyting MQTT connection. 6 | 7 | Closely follows the logic from the official nodejs library, with minor deviations to adapt to Elixir Coding Conventions: 8 | https://github.com/aws/aws-iot-device-sdk-js/blob/master/thing/index.js 9 | 10 | 11 | ## Events 12 | 13 | ### Event :message 14 | 15 | `{:message, topic, message}` 16 | 17 | Emitted when a message is received on a topic not related to any Thing Shadows: 18 | 19 | * `topic` topic of the received packet 20 | * `message` payload of the received packet 21 | 22 | ### Event :status 23 | 24 | `{:status, thing_name, stat, client_token, state_object}` 25 | 26 | Emitted when an operation update|get|delete completes. 27 | 28 | * `thing_name` Emitted when an operation update|get|delete completes. 29 | * `stat` status of the operation :accepted|:rejected 30 | * `client_token` the operation's clientToken 31 | * `state_object` the stateObject returned for the operation 32 | 33 | ### Event :delta 34 | 35 | `{:delta, thing_name, state_object}` 36 | 37 | Emitted when a delta has been received for a registered Thing Shadow. 38 | 39 | * `thing_name` name of the Thing Shadow for which the operation has completed 40 | * `state_object` the stateObject returned for the operation 41 | 42 | ### Event :foreign_state_change 43 | 44 | `{:foreign_state_change, thing_name, operation, state_object}` 45 | 46 | * `thing_name` name of the Thing Shadow for which the operation has completed 47 | * `operation` operation performed by the foreign client :update|:delete 48 | * `state_object` the stateObject returned for the operation 49 | 50 | This event allows an application to be aware of successful update or delete operations performed by different clients. 51 | 52 | ### Event :timeout 53 | 54 | `{:timeout, thing_name, client_token}` 55 | 56 | Emitted when an operation update|get|delete has timed out. 57 | 58 | * `thing_name` name of the Thing Shadow that has received a timeout 59 | * `client_token` the operation's clientToken 60 | 61 | Applications can use clientToken values to correlate timeout events with the operations that they are associated with by saving the clientTokens returned from each operation. 62 | 63 | 64 | ## Note 65 | 66 | Elixir/Erlang's GenEvent executes handlers sequentially in a single loop of the GenEvent.manager process. 67 | To avoid this bottleneck, please ensure that each ThingShadow process has a different `GenEvent.manager`. 68 | Also, please use `ThingShadow.EventHandler` module together with `GenEvent.add_mon_handler/3` if available. 69 | """ 70 | 71 | use GenServer 72 | 73 | require Logger 74 | 75 | ## Client API ## 76 | 77 | @doc """ 78 | Starts a `ThingShadow.Client` process linked to the current process. 79 | There are 2 ways to start the process. 80 | 81 | ## Method 1 82 | - `event_manager` should be a `GenEvent.manager` type, procuded by calling `GenEvent.start_link/1` 83 | - `mqttc_options` should be in the Keyword format that is expected by `:emqttc.start_link/1` 84 | 85 | ## Method 2 86 | - `event_manager` should be a `GenEvent.manager` type, produced by calling `GenEvent.start_link/1` 87 | - `app_name` should be the Mix/OTP application name (atom) that has been configured in config/config.exs file 88 | 89 | The configuration should look like this: 90 | ``` 91 | config :app_name, __MODULE__, 92 | host: "xxxxxxxxx.iot.ap-southeast-1.amazonaws.com", 93 | port: 8883, 94 | client_id: "xxxxxxx", 95 | ca_cert: "config/certs/root-CA.crt", 96 | client_cert: "config/certs/xxxxxxxxxx-certificate.pem.crt", 97 | private_key: "config/certs/xxxxxxxxxx-private.pem.key", 98 | mqttc_opts: [] 99 | ``` 100 | 101 | ## Return values 102 | 103 | If the server is successfully created and initialized, the function returns 104 | `{:ok, pid}`, where pid is the pid of the server. 105 | Use `pid` with other functions defined in this module. 106 | 107 | If the server could not start, the process is terminated and the function returns 108 | `{:error, reason}`, where reason is the error reason. 109 | """ 110 | @spec start_link(GenEvent.manager, atom | [{atom, any}], Keyword.t) :: GenServer.on_start 111 | def start_link(event_manager, mqttc_options_or_app_name \\ :aws, process_options \\ []) 112 | def start_link(event_manager, mqttc_options, process_options) when is_list(mqttc_options) and is_list(process_options) do 113 | # Event_manager is required to emit events. Use: {:ok, event_manager} = GenEvent.start_link([]) 114 | GenServer.start_link(__MODULE__, {event_manager, mqttc_options}, process_options) 115 | end 116 | def start_link(event_manager, app_name, process_options) when is_atom(app_name) and is_list(process_options) do 117 | # Get configuration from environment set by config.exs 118 | mix_config_options = Application.get_env(app_name, __MODULE__) 119 | mqttc_options = [ 120 | host: to_char_list(mix_config_options[:host]), 121 | port: mix_config_options[:port], 122 | client_id: mix_config_options[:client_id], 123 | clean_sess: true, 124 | keepalive: 60, 125 | connack_timeout: 30, 126 | reconnect: {3, 60}, 127 | logger: :info, 128 | ssl: [ 129 | cacertfile: to_char_list(mix_config_options[:ca_cert]), 130 | certfile: to_char_list(mix_config_options[:client_cert]), 131 | keyfile: to_char_list(mix_config_options[:private_key]) 132 | ] 133 | ] 134 | 135 | # Let :emqttc process resubscribe topics when reconnected 136 | mqttc_options = [:auto_resub | mqttc_options] 137 | 138 | # Let mqttc_opt configuration override mqttc_options 139 | mqttc_opts = mix_config_options[:mqttc_opts] 140 | if is_list(mqttc_opts), do: mqttc_options = mqttc_opts ++ mqttc_options 141 | 142 | start_link(event_manager, mqttc_options, process_options) 143 | end 144 | 145 | @doc """ 146 | Get the event_manager of this client 147 | """ 148 | @spec get_event_manager(GenServer.server) :: GenEvent.manager 149 | def get_event_manager(pid) do 150 | GenServer.call(pid, :get_event_manager) 151 | end 152 | 153 | @doc """ 154 | Fetch the event_manager of this client 155 | """ 156 | @spec fetch_event_manager(GenServer.server) :: {:ok, GenEvent.manager} | {:error, term} 157 | def fetch_event_manager(pid) do 158 | try do 159 | event_manager = GenServer.call(pid, :get_event_manager) 160 | {:ok, event_manager} 161 | catch 162 | :exit, e -> {:error, e} 163 | end 164 | end 165 | 166 | @doc """ 167 | Register interest in the Thing Shadow named `thing_name`. The thingShadow process will subscribe to any applicable topics, and will fire events for the Thing Shadow until `unregister/2` is called with `thing_name`. `options` can contain the following arguments to modify how this Thing Shadow is processed: 168 | 169 | `ignore_seltas`: set to `true` to not subscribe to the delta sub-topic for this Thing Shadow; used in cases where the application is not interested in changes (e.g. update only.) (default `false`) 170 | `persistent_subscribe`: set to `false` to unsubscribe from all operation sub-topics while not performing an operation (default `true`) 171 | `discard_stale`: set to `false` to allow receiving messages with old version numbers (default `true`) 172 | `enable_versioning`: set to `true` to send version numbers with shadow updates (default `true`) 173 | 174 | The `persistent_subscribe` argument allows an application to get faster operation responses at the expense of potentially receiving more irrelevant response traffic (i.e., response traffic for other clients who have registered interest in the same Thing Shadow). When persistent_subscribe is set to `false`, operation sub-topics are only subscribed to during the scope of that operation; note that in this mode, update, get, and delete operations will be much slower; however, the application will be less likely to receive irrelevant response traffic. 175 | 176 | The `discard_stale` argument allows applications to receive messages which have obsolete version numbers. This can happen when messages are received out-of-order; applications which set this argument to `false` should use other methods to determine how to treat the data (e.g. use a time stamp property to know how old/stale it is). 177 | 178 | If `enable_versioning` is set to `true`, version numbers will be sent with each operation. AWS IoT maintains version numbers for each shadow, and will reject operations which contain the incorrect version; in applications where multiple clients update the same shadow, clients can use versioning to avoid overwriting each other's changes. 179 | """ 180 | @spec register(GenServer.server, String.t, Keyword.t) :: :ok | {:error, any} 181 | def register(pid, thing_name, options \\ [qos: 1]) when is_binary(thing_name) and is_list(options) do 182 | GenServer.call(pid, {:thing_register, thing_name, options}) 183 | end 184 | 185 | @doc """ 186 | Unregister interest in the Thing Shadow named `thing_name`. The thingShadow process will unsubscribe from all applicable topics and no more events will be fired for thing_name. 187 | """ 188 | @spec unregister(GenServer.server, String.t) :: :ok | {:error, any} 189 | def unregister(pid, thing_name) when is_binary(thing_name) do 190 | GenServer.call(pid, {:thing_unregister, thing_name}) 191 | end 192 | 193 | @doc """ 194 | Update the Thing Shadow named `thing_name` with the state specified in the object `shadow_state_doc`. `thing_name` must have been previously registered using `register/3`. The thingShadow process will subscribe to all applicable topics and publish `shadow_state_doc` on the update sub-topic. 195 | 196 | If the operation is in progress, this function returns `{:ok, client_token}`. 197 | `client_token` is a unique value associated with the update operation. When a 'status' or 'timeout' event is emitted, the client_token will be supplied as one of the parameters, allowing the application to keep track of the status of each operation. The caller may create their own client_token value; if `shadow_state_doc` contains a client_token property, that will be used rather than the internally generated value. Note that it should be of atomic type (i.e. numeric or string). This function returns `nil` if an operation is already in progress. 198 | 199 | `operation_timeout` (milliseconds). If no accepted or rejected response to a thing operation is received within this time, subscriptions to the accepted and rejected sub-topics for a thing are cancelled. 200 | """ 201 | @spec update(GenServer.server, String.t, %{atom => any}) :: {:ok, binary} | {:error, any} 202 | def update(pid, thing_name, shadow_state_doc, operation_timeout \\ 10000) when is_binary(thing_name) and is_map(shadow_state_doc) and operation_timeout >= 0 do 203 | #shadow_state_doc = %{ 204 | # state: %{ 205 | # reported: %{ 206 | # "awsSqsSyncLastMessageId" => "3c52e73a-284c-4dc1-80d6-2d01d64f5b35" 207 | # } 208 | # } 209 | #} 210 | 211 | GenServer.call(pid, {:thing_operation, :update, thing_name, shadow_state_doc, operation_timeout}) 212 | end 213 | 214 | @doc """ 215 | Get the current state of the Thing Shadow named `thing_name`, which must have been previously registered using `register/3`. The thingShadow process will subscribe to all applicable topics and publish on the get sub-topic. 216 | 217 | If the operation is in progress, this function returns `{:ok, client_token}`. 218 | `client_token` is a unique value associated with the get operation. When a 'status or 'timeout' event is emitted, the client_token will be supplied as one of the parameters, allowing the application to keep track of the status of each operation. The caller may supply their own client_token value (optional); if supplied, the value of client_token will be used rather than the internally generated value. Note that this value should be of atomic type (i.e. numeric or string). This function returns `nil` if an operation is already in progress. 219 | 220 | `operation_timeout` (milliseconds). If no accepted or rejected response to a thing operation is received within this time, subscriptions to the accepted and rejected sub-topics for a thing are cancelled. 221 | """ 222 | @spec get(GenServer.server, String.t, String.t) :: {:ok, binary} | {:error, any} 223 | def get(pid, thing_name, client_token \\ "", operation_timeout \\ 10000) when is_binary(thing_name) and is_binary(client_token) and operation_timeout >= 0 do 224 | shadow_state_doc = if (client_token == ""), do: %{}, else: %{clientToken: client_token} 225 | GenServer.call(pid, {:thing_operation, :get, thing_name, shadow_state_doc, operation_timeout}) 226 | end 227 | 228 | @doc """ 229 | Delete the Thing Shadow named `thing_name`, which must have been previously registered using `register/3`. The thingShadow process will subscribe to all applicable topics and publish on the delete sub-topic. 230 | 231 | If the operation is in progress, this function returns `{:ok, client_token}`. 232 | `client_token` is a unique value associated with the delete operation. When a 'status' or 'timeout' event is emitted, the client_token will be supplied as one of the parameters, allowing the application to keep track of the status of each operation. The caller may supply their own client_token value (optional); if supplied, the value of client_token will be used rather than the internally generated value. Note that this value should be of atomic type (i.e. numeric or string). This function returns `nil` if an operation is already in progress. 233 | 234 | `operation_timeout` (milliseconds). If no accepted or rejected response to a thing operation is received within this time, subscriptions to the accepted and rejected sub-topics for a thing are cancelled. 235 | """ 236 | @spec delete(GenServer.server, String.t, String.t) :: {:ok, binary} | {:error, any} 237 | def delete(pid, thing_name, client_token \\ "", operation_timeout \\ 10000) when is_binary(thing_name) and is_binary(client_token) and operation_timeout >= 0 do 238 | shadow_state_doc = if (client_token == ""), do: %{}, else: %{clientToken: client_token} 239 | GenServer.call(pid, {:thing_operation, :get, thing_name, shadow_state_doc, operation_timeout}) 240 | end 241 | 242 | @doc """ 243 | Identical to the `:emqttc.publish/3` method, with the restriction that the topic may not represent a Thing Shadow. This method allows the user to publish messages to topics on the same connection used to access Thing Shadows. 244 | """ 245 | @spec publish(GenServer.server, String.t, any, Keyword.t) :: :ok | {:error, any} 246 | def publish(pid, topic, payload, options \\ [qos: 0]) when is_binary(topic) and is_list(options) do 247 | GenServer.call(pid, {:thing_publish, topic, payload, options}) 248 | end 249 | 250 | @doc """ 251 | Identical to the `:emqttc.subscribe/2` method, with the restriction that the topic may not represent a Thing Shadow. This method allows the user to subscribe to messages from topics on the same connection used to access Thing Shadows. 252 | """ 253 | @spec subscribe(GenServer.server, String.t | {String.t, atom | integer} | [{String.t, atom | integer}], Keyword.t) :: :ok | {:error, any} 254 | def subscribe(pid, topics, options \\ [qos: 0]) 255 | def subscribe(pid, topics_with_qos = [ {topic, qos} | _ ], options) when is_binary(topic) and (is_atom(qos) or is_integer(qos)) and is_list(options) do 256 | GenServer.call(pid, {:thing_subscribe, topics_with_qos, options}) 257 | end 258 | def subscribe(pid, topic_with_qos = {topic, qos}, options) when is_binary(topic) and (is_atom(qos) or is_integer(qos)) and is_list(options) do 259 | GenServer.call(pid, {:thing_subscribe, topic_with_qos, options}) 260 | end 261 | def subscribe(pid, topic, options) when is_binary(topic) and is_list(options) do 262 | if options[:qos] == nil do 263 | {:error, "options is missing qos"} 264 | else 265 | GenServer.call(pid, {:thing_subscribe, topic, options}) 266 | end 267 | end 268 | 269 | @doc """ 270 | Identical to the `:emqttc.unsubscribe/1` method, with the restriction that the topic may not represent a Thing Shadow. This method allows the user to unsubscribe from topics on the same used to access Thing Shadows. 271 | """ 272 | @spec unsubscribe(GenServer.server, String.t | [String.t]) :: :ok | {:error, any} 273 | def unsubscribe(pid, topics = [ topic | _ ]) when is_binary(topic) do 274 | GenServer.call(pid, {:thing_unsubscribe, topics}) 275 | end 276 | def unsubscribe(pid, topic) when is_binary(topic) do 277 | GenServer.call(pid, {:thing_unsubscribe, topic}) 278 | end 279 | 280 | @doc """ 281 | Invokes the `GenServer.stop/3` method on the thingShadow process. This causes `terminate/2` to be called and the MQTT connection owned by the thingShadow process to be disconnected. The force parameters is optional and identical in function to the `:shutdown` reason parameter in the `GenServer.stop/3` method. 282 | """ 283 | @spec stop(GenServer.server, atom) :: :ok 284 | def stop(pid, reason \\ :normal) 285 | def stop(pid, true) do 286 | GenServer.stop(pid, :force, :infinity) 287 | end 288 | def stop(pid, reason) do 289 | GenServer.stop(pid, reason, :infinity) 290 | end 291 | 292 | 293 | 294 | ## GenServer Callbacks ## 295 | 296 | @doc """ 297 | Responsible for connecting to the MQTT broker 298 | """ 299 | def init({event_manager, mqttc_options}) do 300 | # GenEvent.manager needs to be monitored (on top of supervision). 301 | # Monitor means that ThingShadow.Client process should stop when its event_manager exits/crashes. 302 | # However, the event_manager should not stop if the ThingShadow.Client process crashes. 303 | _monitor_ref = Process.monitor(event_manager) 304 | 305 | try do 306 | {:ok, client} = :emqttc.start_link(mqttc_options) 307 | 308 | initial_state = %{ 309 | mqttc: client, 310 | mqttc_state: :disconnected, 311 | pending_calls: [], 312 | event_manager: event_manager, 313 | thing_shadows: %{}, 314 | client_id: mqttc_options[:client_id], 315 | seq: 0 316 | } 317 | 318 | {:ok, initial_state } 319 | 320 | rescue 321 | e -> {:stop, e} 322 | end 323 | 324 | end 325 | 326 | 327 | @doc """ 328 | Responsible for handling thingshadow register-interest calls 329 | 330 | ## Options ## 331 | * qos: set to the desired QoS level :qos0 or :qos1 for the MQTT messages (default :qos0) 332 | * ignore_deltas: set to true to not subscribe to the delta sub-topic for this Thing Shadow; used in cases where the application is not interested in changes (e.g. update only.) (default false) 333 | * persistent_subscribe: set to false to unsubscribe from all operation sub-topics while not performing an operation (default true) 334 | * discard_stale: set to false to allow receiving messages with old version numbers (default true) 335 | * enable_versioning: set to true to send version numbers with shadow updates (default true) 336 | """ 337 | def handle_call({:thing_register, thing_name, opts}, _from, state = %{mqttc_state: :connected, mqttc: client, thing_shadows: thing_shadows}) when is_binary(thing_name) and is_list(opts) do 338 | # DONE: Implement version keeping and options[:discard_stale] 339 | # DONE: Delay subscribe to during update/delete operation only, if options[:persistent_subscribe] 340 | # DONE: Send version numbers with shadow updates if options[:enable_versioning] 341 | # DONE: Do not subscribe to delta topics if options[:ignore_deltas] 342 | 343 | ignore_deltas = Keyword.get(opts, :ignore_deltas, false) 344 | persistent_subscribe = Keyword.get(opts, :persistent_subscribe, true) 345 | discard_stale = Keyword.get(opts, :discard_stale, true) 346 | enable_versioning = Keyword.get(opts, :enable_versioning, true) 347 | qos = Keyword.get(opts, :qos, :qos0) 348 | 349 | current_thing = %{ 350 | operation_timers: %{}, 351 | persistent_subscribe: persistent_subscribe, 352 | discard_stale: discard_stale, 353 | enable_versioning: enable_versioning, 354 | qos: qos 355 | } 356 | 357 | new_state = %{state | thing_shadows: Map.put_new(thing_shadows, thing_name,current_thing) } 358 | 359 | # Exclude delta topic if ignore_delta == true 360 | subscribe_result = case ignore_deltas do 361 | false -> 362 | case persistent_subscribe do 363 | true -> 364 | handle_subscriptions(client, {:subscribe, thing_name, [:update, :get, :delete], [:delta, :accepted, :rejected]}, current_thing) 365 | false -> 366 | handle_subscriptions(client, {:subscribe, thing_name, [:update], [:delta]}, current_thing) 367 | end 368 | 369 | true -> 370 | case persistent_subscribe do 371 | true -> 372 | handle_subscriptions(client, {:subscribe, thing_name, [:update, :get, :delete], [:accepted, :rejected]}, current_thing) 373 | false -> 374 | # Do nothing 375 | :ok 376 | end 377 | end 378 | 379 | case subscribe_result do 380 | :ok -> 381 | # Update to new state on ok 382 | {:reply, :ok, new_state} 383 | 384 | {:error, reason} -> 385 | # Keep previous state on error 386 | {:reply, {:error, reason}, state} 387 | end 388 | end 389 | def handle_call(request = {:thing_register, thing_name, opts}, from, state = %{mqttc_state: :disconnected, pending_calls: pending_calls}) when is_binary(thing_name) and is_list(opts) do 390 | # Block the caller if we know we are offline (to prevent mailbox overflow) 391 | {:noreply, %{state | pending_calls: [ {request, from} | pending_calls]}} 392 | end 393 | 394 | @doc """ 395 | Responsible for handling thingshadow update/get/delete operation-requests for a registered thing 396 | """ 397 | def handle_call({:thing_operation, operation, thing_name, shadow_state_doc, operation_timeout}, _from, state = %{mqttc_state: :connected, mqttc: client, thing_shadows: thing_shadows, client_id: client_id, seq: seq}) when is_atom(operation) and is_binary(thing_name) do 398 | case thing_shadows do 399 | %{^thing_name => current_thing} -> 400 | # Generate client_token if missing from shadow_state_doc 401 | client_token = shadow_state_doc[:clientToken] || shadow_state_doc["clientToken"] || "#{client_id}-#{seq}" 402 | shadow_state_doc = Map.put_new(shadow_state_doc, :clientToken, client_token) 403 | 404 | # Subscribe to response topics (if persistent_subscribe is false) 405 | case handle_persistent_subscriptions(client, {:subscribe, thing_name, [operation], [:accepted, :rejected]}, current_thing) do 406 | :ok -> 407 | # After a period of time, we are no longer interested in the accepted/rejected response, so we unsubscribe (if persistent_subscribe is false) 408 | operation_timer = Process.send_after(self(), {:thing_operation_timeout, thing_name, operation, client_token}, operation_timeout) 409 | Logger.debug "[#{thing_name}] Started #{operation} operation timeout timer for client token: #{client_token}" 410 | 411 | publish_topic = build_thing_shadow_topic(thing_name, operation) 412 | case publish_shadow_doc_to_topic(client, publish_topic, shadow_state_doc, current_thing) do 413 | :ok -> 414 | # Add client_token to operation_timers in current_thing 415 | current_thing = current_thing |> Map.update(:operation_timers, %{client_token => operation_timer}, fn current_operation_timers -> 416 | Map.put_new(current_operation_timers, client_token, operation_timer) 417 | end) 418 | # Update state of operation_timers in current_thing and increment seq 419 | thing_shadows = %{thing_shadows | thing_name => current_thing} 420 | {:reply, {:ok, client_token}, %{state | thing_shadows: thing_shadows, seq: seq+1 } } 421 | 422 | other -> 423 | # Would rather not reuse sequence number even on publish error, 424 | # in case message reaches broker eventually, causing AWS IoT responses with same client_token 425 | {:reply, other, %{state | seq: seq+1 }} 426 | end 427 | 428 | other -> 429 | {:reply, other, state} 430 | end 431 | 432 | _no_match -> 433 | {:reply, {:error, "Attempting to #{operation} unknown thing: #{thing_name}. Please register beforehand."}, state } 434 | end 435 | end 436 | def handle_call(request = {:thing_operation, operation, thing_name, _shadow_state_doc, _operation_timeout}, from, state = %{mqttc_state: :disconnected, pending_calls: pending_calls}) when is_atom(operation) and is_binary(thing_name) do 437 | # Block the caller if we know we are offline (to prevent mailbox overflow) 438 | {:noreply, %{state | pending_calls: [ {request, from} | pending_calls]}} 439 | end 440 | 441 | @doc """ 442 | Responsible for handling publish requests on non-thing topics 443 | """ 444 | def handle_call({:thing_publish, topic, payload, opts}, _from, state = %{mqttc: client}) when is_binary(topic) do 445 | publish_result = :emqttc.publish(client, topic, payload, opts) 446 | {:reply, publish_result, state} 447 | end 448 | 449 | @doc """ 450 | Responsible for handling subscribe requests on non-thing topics 451 | """ 452 | def handle_call({:thing_subscribe, topics, _opts}, _from, state = %{mqttc: client}) when is_list(topics) do 453 | subscribe_result = :emqttc.subscribe(client, topics) 454 | {:reply, subscribe_result, state} 455 | end 456 | def handle_call({:thing_subscribe, topic, _opts}, _from, state = %{mqttc: client}) when is_tuple(topic) do 457 | subscribe_result = :emqttc.subscribe(client, topic) 458 | {:reply, subscribe_result, state} 459 | end 460 | def handle_call({:thing_subscribe, topic, opts}, _from, state = %{mqttc: client}) when is_binary(topic) do 461 | subscribe_result = :emqttc.subscribe(client, topic, opts[:qos]) 462 | {:reply, subscribe_result, state} 463 | end 464 | 465 | @doc """ 466 | Responsible for handling unsubscribe requests on non-thing topics 467 | """ 468 | def handle_call({:thing_unsubscribe, topics}, _from, state = %{mqttc: client}) when is_list(topics) do 469 | unsubscribe_result = :emqttc.unsubscribe(client, topics) 470 | {:reply, unsubscribe_result, state} 471 | end 472 | def handle_call({:thing_unsubscribe, topic}, _from, state = %{mqttc: client}) when is_binary(topic) do 473 | unsubscribe_result = :emqttc.unsubscribe(client, topic) 474 | {:reply, unsubscribe_result, state} 475 | end 476 | 477 | @doc """ 478 | Responsible for handling :get_event_manager requests 479 | """ 480 | def handle_call(:get_event_manager, _from, state = %{event_manager: event_manager}) do 481 | {:reply, event_manager, state} 482 | end 483 | 484 | 485 | @doc """ 486 | Responsible for receiving MQTT broker connected event from mqttc client 487 | """ 488 | def handle_info({:mqttc, client, :connected}, state = %{mqttc: client, pending_calls: pending_calls, event_manager: handlers}) do 489 | Logger.debug "MQTT client #{inspect(client)} is connected" 490 | 491 | state = %{state | mqttc_state: :connected } 492 | state = pending_calls 493 | |> Enum.reverse 494 | |> Enum.reduce(state, fn ({request, from}, last_state) -> 495 | case handle_call(request, from, last_state) do 496 | {:reply, reply = :ok, next_state} -> 497 | :ok = GenServer.reply(from, reply) 498 | next_state |> Map.update(:pending_calls, [], fn pending_calls -> tl(pending_calls) end) 499 | 500 | {:reply, {:error, reason}, next_state} -> 501 | Logger.warn "Error when processing pending_call #{request}: #{reason}" 502 | next_state 503 | 504 | _other -> 505 | last_state 506 | end 507 | end) 508 | 509 | GenEvent.ack_notify(handlers, {:connect, self}) 510 | {:noreply, state} 511 | end 512 | 513 | @doc """ 514 | Responsible for receiving MQTT broker disconnected event from mqttc client 515 | """ 516 | def handle_info({:mqttc, client, :disconnected}, state = %{mqttc: client, event_manager: handlers}) do 517 | Logger.debug "MQTT client #{inspect(client)} is disconnected" 518 | 519 | state = %{state | mqttc_state: :disconnected } 520 | GenEvent.ack_notify(handlers, {:offline, self}) 521 | {:noreply, state} 522 | end 523 | 524 | @doc """ 525 | Responsible for receiving event_manager down notification. 526 | ThingShadow.Client process should stop when event_manager is dead. 527 | """ 528 | def handle_info({:DOWN, _ref, :process, {event_manager, _node}, _reason}, state = %{event_manager: event_manager} ) do 529 | {:stop, :event_manager_down, state} 530 | end 531 | 532 | @doc """ 533 | Responsible for receiving operation timeout messages 534 | """ 535 | def handle_info({:thing_operation_timeout, thing_name, operation, client_token}, state = %{mqttc: client, event_manager: handlers, thing_shadows: thing_shadows}) do 536 | case thing_shadows do 537 | %{^thing_name => current_thing} -> 538 | # Unsubscribe from response topics (if persistent_subscribe is false) 539 | handle_persistent_subscriptions(client, {:unsubscribe, thing_name, [operation], [:accepted, :rejected]}, current_thing) 540 | # Notify timeout event handlers 541 | GenEvent.ack_notify(handlers, {:timeout, thing_name, client_token}) 542 | # Remove client_token from operation_timers in current_thing 543 | current_thing = current_thing |> Map.update(:operation_timers, %{}, fn current_operation_timers -> 544 | current_operation_timers |> Map.delete(client_token) 545 | end) 546 | # Update state of operation_timers in current_thing 547 | thing_shadows = %{thing_shadows | thing_name => current_thing} 548 | {:noreply, %{state | thing_shadows: thing_shadows}} 549 | 550 | _no_match -> 551 | {:noreply, state} 552 | end 553 | end 554 | 555 | @doc """ 556 | Responsible for receiving MQTT messages from mqttc client 557 | """ 558 | def handle_info({:publish, topic, payload}, state) do 559 | #Logger.debug "Message from #{topic}: #{payload}" 560 | 561 | # Handle topics by pattern-matching 562 | topic |> String.split("/", parts: 6) |> handle_topic_message(payload, state) 563 | end 564 | 565 | @doc """ 566 | Responsible for receiving MQTT messages from thingshadow topics of registered things 567 | """ 568 | def handle_topic_message(topic_iolist = ["$aws", "things", thing_name, "shadow", "update", "delta"], payload, state = %{event_manager: handlers, thing_shadows: thing_shadows}) when is_binary(thing_name) do 569 | # Handle delta response message 570 | case thing_shadows do 571 | %{^thing_name => current_thing} -> 572 | Logger.debug "[#{thing_name}] Parsing JSON of delta message payload." 573 | 574 | case Poison.Parser.parse(payload) do 575 | {:ok, shadow_state_doc} -> 576 | client_token = shadow_state_doc["clientToken"] || shadow_state_doc[:clientToken] 577 | version = shadow_state_doc["version"] || shadow_state_doc[:version] 578 | 579 | Logger.debug "[#{thing_name}] Decoded shadow delta with version: #{version}" 580 | 581 | case update_version_in_thing(current_thing, version, :update) do 582 | nil -> 583 | # Do nothing as message is to be discarded 584 | Logger.info "[#{thing_name}] Discarding outdated shadow delta with client token: #{client_token}" 585 | {:noreply, state} 586 | current_thing -> 587 | Logger.info "[#{thing_name}] Receiving new shadow delta with client token: #{client_token}" 588 | # Notify delta event handlers 589 | GenEvent.ack_notify(handlers, {:delta, thing_name, shadow_state_doc}) 590 | # Update state of version in current_thing 591 | thing_shadows = %{thing_shadows | thing_name => current_thing} 592 | {:noreply, %{state | thing_shadows: thing_shadows}} 593 | end 594 | 595 | {:error, _reason} -> 596 | {:noreply, state} 597 | end 598 | 599 | _no_match -> 600 | GenEvent.ack_notify(handlers, {:message, to_string(topic_iolist), payload}) 601 | {:noreply, state} 602 | end 603 | end 604 | def handle_topic_message(topic_iolist = ["$aws", "things", thing_name, "shadow", operation, status], payload, state = %{mqttc: client, event_manager: handlers, thing_shadows: thing_shadows}) when is_binary(thing_name) and is_binary(operation) and is_binary(status) do 605 | # Handle accepted/rejected response message 606 | case thing_shadows do 607 | %{^thing_name => current_thing} -> 608 | # Convert string to atoms 609 | operation = case operation do 610 | "update" -> :update 611 | "get" -> :get 612 | "delete" -> :delete 613 | end 614 | status = case status do 615 | "accepted" -> :accepted 616 | "rejected" -> :rejected 617 | end 618 | 619 | Logger.debug "[#{thing_name}] Parsing JSON of #{status}-#{operation} message payload." 620 | 621 | case Poison.Parser.parse(payload) do 622 | {:ok, shadow_state_doc} -> 623 | client_token = shadow_state_doc["clientToken"] || shadow_state_doc[:clientToken] 624 | version = shadow_state_doc["version"] || shadow_state_doc[:version] 625 | 626 | Logger.debug "[#{thing_name}] Decoded #{status}-#{operation} shadow document with version: #{version}" 627 | 628 | case update_version_in_thing(current_thing, version, operation) do 629 | nil -> 630 | # Do nothing as message is to be discarded 631 | Logger.info "[#{thing_name}] Discarding outdated shadow document with client token: #{client_token}" 632 | {:noreply, state} 633 | 634 | current_thing = %{operation_timers: current_operation_timers = %{^client_token => operation_timer} } -> 635 | Logger.info "[#{thing_name}] Receiving #{status} shadow document for #{operation} operation with client token: #{client_token}" 636 | # Cancel operation_timeout timer 637 | if (operation_timer != nil), do: Process.cancel_timer(operation_timer) 638 | Logger.debug "[#{thing_name}] Cancelled #{operation} operation timeout timer for client token: #{client_token}" 639 | # Remove client_token from operation_timers in current_thing 640 | {_operation_timer, other_operation_timers} = current_operation_timers |> Map.pop(client_token) 641 | current_thing = %{ current_thing | operation_timers: other_operation_timers} 642 | # Unsubscribe if persistent_subscribe is false 643 | handle_persistent_subscriptions(client, {:unsubscribe, thing_name, [operation], [:accepted, :rejected]}, current_thing) 644 | # Notify status event handlers 645 | GenEvent.ack_notify(handlers, {:status, thing_name, status, client_token, shadow_state_doc}) 646 | # Update state of version and operation_timers in current_thing 647 | thing_shadows = %{thing_shadows | thing_name => current_thing} 648 | {:noreply, %{state | thing_shadows: thing_shadows}} 649 | 650 | current_thing when status == :accepted and operation != :get -> 651 | Logger.info "[#{thing_name}] Receiving accepted shadow document from foreign #{operation} operation." 652 | # This operation is not made from this client 653 | GenEvent.ack_notify(handlers, {:foreign_state_change, thing_name, operation, shadow_state_doc}) 654 | # Update state of version in current_thing 655 | thing_shadows = %{thing_shadows | thing_name => current_thing} 656 | {:noreply, %{state | thing_shadows: thing_shadows}} 657 | 658 | current_thing when true -> 659 | Logger.info "[#{thing_name}] Receiving #{status} shadow document from foreign #{operation} operation." 660 | # Just update the version state as this operation is not made from this client, and is not relevant to local event handlers 661 | thing_shadows = %{thing_shadows | thing_name => current_thing} 662 | {:noreply, %{state | thing_shadows: thing_shadows}} 663 | end 664 | 665 | {:error, _reason} -> 666 | {:noreply, state} 667 | end 668 | 669 | _no_match -> 670 | GenEvent.ack_notify(handlers, {:message, to_string(topic_iolist), payload}) 671 | {:noreply, state} 672 | end 673 | end 674 | def handle_topic_message(topic_iolist, payload, state = %{event_manager: handlers}) when is_list(topic_iolist) do 675 | # Handle messages from other topics 676 | Logger.info "Receiving Generic message from #{topic_iolist}: #{payload}" 677 | GenEvent.ack_notify(handlers, {:message, to_string(topic_iolist), payload}) 678 | {:noreply, state} 679 | end 680 | 681 | def terminate(reason, state = %{mqttc: client, event_manager: handlers} ) do 682 | if Process.alive?(client) do 683 | :ok = :emqttc.disconnect(client) 684 | GenEvent.ack_notify(handlers, {:close, self}) 685 | end 686 | super(reason, state) 687 | end 688 | 689 | 690 | 691 | ## Helper Functions ## 692 | 693 | ### Handles subscribe and unsubscribe actions on thingshadow topics 694 | defp handle_subscriptions(client, {:subscribe, thing_name, operations, statii}, _thing_opts = %{qos: qos}) when is_binary(thing_name) do 695 | topics = build_thing_shadow_topics(thing_name, operations, statii) 696 | topics_with_qos = topics |> Enum.map(fn topic -> {topic, qos} end) 697 | 698 | case :emqttc.sync_subscribe(client, topics_with_qos) do 699 | {:ok, _} -> :ok 700 | other -> other 701 | end 702 | 703 | # Bug filed under https://github.com/emqtt/emqttc/issues/37 704 | # :emqttc.sync_subscribe(client, topics_with_qos) 705 | end 706 | defp handle_subscriptions(client, {:unsubscribe, thing_name, operations, statii}, _thing_opts) when is_binary(thing_name) do 707 | topics = build_thing_shadow_topics(thing_name, operations, statii) 708 | :emqttc.unsubscribe(client, topics) 709 | end 710 | 711 | ### Handles persistent subscribe and unsubscribe actions on thingshadow topics 712 | defp handle_persistent_subscriptions(_client, _topic_params = {_, thing_name, _operations, _statii}, _thing_opts = %{persistent_subscribe: true}) when is_binary(thing_name) do 713 | :ok 714 | end 715 | defp handle_persistent_subscriptions(client, topic_params = {_, thing_name, _operations, _statii}, thing_opts = %{persistent_subscribe: false}) when is_binary(thing_name) do 716 | handle_subscriptions(client, topic_params, thing_opts) 717 | end 718 | 719 | ### Publish thingshadow shadow_state_doc to MQTT topic with or without versioning 720 | defp publish_shadow_doc_to_topic(client, topic, shadow_state_doc, _current_thing = %{qos: qos, enable_versioning: true, version: version}) when is_binary(topic) do 721 | # Enable_versioning is true and version is available 722 | shadow_state_doc = Map.put_new(shadow_state_doc, :version, version) 723 | 724 | # Encode shadow_state_doc to json, and then Asynchronous publish to topic. If qos1, emqttc will queue the resend. 725 | shadow_state_doc_json = shadow_state_doc |> Poison.Encoder.encode([]) |> to_string 726 | :emqttc.publish(client, topic, shadow_state_doc_json, qos) 727 | end 728 | defp publish_shadow_doc_to_topic(client, topic, shadow_state_doc, _current_thing = %{qos: qos, enable_versioning: _}) when is_binary(topic) do 729 | # Version is unavailable or Enable_versioning is false 730 | # Encode shadow_state_doc to json, and then Asynchronous publish to topic. If qos1, emqttc will queue the resend. 731 | shadow_state_doc_json = shadow_state_doc |> Poison.Encoder.encode([]) |> to_string 732 | :emqttc.publish(client, topic, shadow_state_doc_json, qos) 733 | end 734 | 735 | ### Update version in thing map based on whether `new_version` is indeed newer than `current_version` 736 | defp update_version_in_thing(current_thing, nil, _operation) do 737 | # New_version is undefined 738 | current_thing 739 | end 740 | defp update_version_in_thing(current_thing, _new_version, _operation = :rejected) do 741 | # Operation is rejected 742 | current_thing 743 | end 744 | defp update_version_in_thing(current_thing = %{version: current_version}, new_version, _operation) when new_version > current_version do 745 | # New_version is newer than current_version (needed for accepted message) 746 | %{current_thing | version: new_version} 747 | end 748 | defp update_version_in_thing(current_thing = %{version: current_version}, new_version, _operation) when new_version == current_version do 749 | # New_version is same as current_version (needed for delta message) 750 | current_thing 751 | end 752 | defp update_version_in_thing(current_thing = %{version: _current_version, discard_stale: _}, _new_version, _operation = :delete) do 753 | # New_version is older than current_version, but operation is delete 754 | # Do not discard no matter the value of discard_stale option. 755 | current_thing 756 | end 757 | defp update_version_in_thing(current_thing = %{version: _current_version, discard_stale: false}, _new_version, _operation) do 758 | # New_version is older than current_version, but discard_stale option is false 759 | # Do not discard as discard_stale = false 760 | current_thing 761 | end 762 | defp update_version_in_thing(_current_thing = %{version: _current_version, discard_stale: true}, _new_version, _operation) do 763 | # New_version is older than current_version, and discard_stale option is true 764 | # Indicate discard whole message by returning nil 765 | nil 766 | end 767 | defp update_version_in_thing(current_thing = %{}, new_version, _operation) do 768 | # Current_version is undefined 769 | Map.put_new(current_thing, :version, new_version) 770 | end 771 | 772 | 773 | 774 | 775 | @doc """ 776 | Builds thingshadow topic according to AWS Spec 777 | """ 778 | def build_thing_shadow_topic(thing_name, operation, status \\ nil) 779 | def build_thing_shadow_topic(thing_name, operation = :update, status = :delta) do 780 | "$aws/things/#{thing_name}/shadow/#{operation}/#{status}" 781 | end 782 | def build_thing_shadow_topic(_thing_name, _operation, _status = :delta) do 783 | # Delta is only valid for update operation. 784 | nil 785 | end 786 | def build_thing_shadow_topic(thing_name, operation, _status = nil) do 787 | # Used to build a topic to publish to 788 | "$aws/things/#{thing_name}/shadow/#{operation}" 789 | end 790 | def build_thing_shadow_topic(thing_name, operation, status) do 791 | # Used to build a topic to subscribe to 792 | "$aws/things/#{thing_name}/shadow/#{operation}/#{status}" 793 | end 794 | 795 | @doc """ 796 | Builds multiple thingshadow topics at once and returns a flat list 797 | """ 798 | def build_thing_shadow_topics(thing_name, operations, statii) when is_binary(thing_name) and is_list(operations) and is_list(statii) do 799 | Enum.flat_map(operations, fn operation -> 800 | statii 801 | |> Enum.map(&build_thing_shadow_topic(thing_name, operation, &1)) 802 | |> Enum.filter(&(&1)) 803 | end) 804 | end 805 | 806 | end -------------------------------------------------------------------------------- /lib/aws/iot/thing_shadow/event_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Aws.Iot.ThingShadow.EventHandler do 2 | 3 | @moduledoc ~S""" 4 | GenEvent executes handlers sequentially in a single loop of the GenEvent.manager process. 5 | To avoid this bottleneck, we ensure that handlers do nothing other than forwarding events (i.e. delegation). 6 | Also, please use `GenEvent.add_mon_handler/3` if available, so that the calling process that adds the handler would receive the message `{:gen_event_EXIT, handler, reason}` if the event handler is later deleted. 7 | 8 | ## Usage 9 | iex> {:ok, event_manager} = GenEvent.start_link([]) 10 | iex> GenEvent.add_mon_handler(event_manager, EventHandler, [broadcast: {MyApp.PubSub, "thing_shadow_event"}]) 11 | 12 | """ 13 | 14 | use GenEvent 15 | alias Phoenix.PubSub 16 | 17 | @backpressure_timeout 5000 18 | 19 | ## Client API ## 20 | 21 | @doc """ 22 | Provideds an external API to make it easy to do garbage collection on `manager`. 23 | 24 | Only required to be called once for each GenEvent.manager, as the GenEvent.manager will perform garbage-collection for all registered handlers, before processing the next message. 25 | """ 26 | @spec hibernate(GenEvent.handler, GenEvent.manager) :: :ok 27 | def hibernate(handler \\ __MODULE__, manager) do 28 | GenEvent.call(manager, handler, :hibernate) 29 | end 30 | 31 | 32 | @doc """ 33 | Adds `pubsub_topic` to the list of GenServers that would be forwarded any incoming event from `manager` using `Phoenix.PubSub.broadcast/3` 34 | 35 | This function is not necessary (as more of the same GenEvent.handler could be added to a GenEvent.manager), but is provided for completeness. 36 | """ 37 | @spec add_broadcast(GenEvent.handler, GenEvent.manager, {GenServer.server, binary}) :: :ok 38 | def add_broadcast(handler \\ __MODULE__, manager, pubsub_topic = {_pubsub_server, _topic}) do 39 | GenEvent.call(manager, handler, {:add_broadcast, pubsub_topic}) 40 | end 41 | 42 | @doc """ 43 | Removes `pubsub_topic` from the list of GenServers that would be forwarded any incoming event from `manager` using `Phoenix.PubSub.broadcast/3` 44 | 45 | This function is not necessary (as more of the same GenEvent.handler could be added to a GenEvent.manager), but is provided for completeness. 46 | """ 47 | @spec remove_broadcast(GenEvent.handler, GenEvent.manager, {GenServer.server, binary}) :: :ok 48 | def remove_broadcast(handler \\ __MODULE__, manager, pubsub_topic = {_pubsub_server, _topic}) do 49 | GenEvent.call(manager, handler, {:remove_broadcast, pubsub_topic}) 50 | end 51 | 52 | @doc """ 53 | Adds `server_process` to the list of GenServers that would be forwarded any incoming event from `manager` using `Genserver.cast/2` 54 | 55 | This function is not necessary (as more of the same GenEvent.handler could be added to a GenEvent.manager), but is provided for completeness. 56 | """ 57 | @spec add_cast(GenEvent.handler, GenEvent.manager, GenServer.server) :: :ok 58 | def add_cast(handler \\ __MODULE__, manager, server_process) do 59 | GenEvent.call(manager, handler, {:add_cast, server_process}) 60 | end 61 | 62 | @doc """ 63 | Removes `server_process` from the list of GenServers that would be forwarded any incoming event from `manager` using `Genserver.cast/2` 64 | 65 | This function is not necessary (as more of the same GenEvent.handler could be added to a GenEvent.manager), but is provided for completeness. 66 | """ 67 | @spec remove_cast(GenEvent.handler, GenEvent.manager, GenServer.server) :: :ok 68 | def remove_cast(handler \\ __MODULE__, manager, server_process) do 69 | GenEvent.call(manager, handler, {:remove_cast, server_process}) 70 | end 71 | 72 | @doc """ 73 | Adds `server_process` to the list of GenServers that would be forwarded any incoming event from `manager` using `Genserver.call/2` 74 | 75 | This function is required so there can be a single backpressure timeout. 76 | Many more of the same GenEvent.handler added to a GenEvent.manager would only increase the overall backpressure timeout. 77 | """ 78 | @spec add_call(GenEvent.handler, GenEvent.manager, GenServer.server) :: :ok 79 | def add_call(handler \\ __MODULE__, manager, server_process) do 80 | GenEvent.call(manager, handler, {:add_call, server_process}) 81 | end 82 | 83 | @doc """ 84 | Removes `server_process` from the list of GenServers that would be forwarded any incoming event from `manager` using `Genserver.call/2` 85 | 86 | This function is required so there can be a single backpressure timeout. 87 | Many more of the same GenEvent.handler added to a GenEvent.manager would only increase the overall backpressure timeout. 88 | """ 89 | @spec remove_call(GenEvent.handler, GenEvent.manager, GenServer.server) :: :ok 90 | def remove_call(handler \\ __MODULE__, manager, server_process) do 91 | GenEvent.call(manager, handler, {:remove_call, server_process}) 92 | end 93 | 94 | 95 | ## GenEvent Handler Callbacks ## 96 | 97 | @doc """ 98 | Initializes the state of the handler during `GenEvent.add_handler/3` (and `GenEvent.add_mon_handler/3`) 99 | 100 | - `args` should be in the form of [broadcast: pubsub_bus1, send: otp_process1, cast: genserver_process1, cast: genserver_process2]. 101 | Use the form of [call: server_process1] only when backpressure is desired to prevent too many pending async messages in process mailbox. 102 | """ 103 | def init(args) when is_list(args) do 104 | init(args, %{broadcast_to: [], send_to: [], cast_to: [], call_to: []}) 105 | end 106 | def init([{:broadcast, pubsub_topic = {_pubsub_server, _topic}} | args], state = %{broadcast_to: broadcast_to}) do 107 | if Code.ensure_loaded?(PubSub) do 108 | init(args, %{state | broadcast_to: [pubsub_topic | broadcast_to] }) 109 | else 110 | {:error, "Specified broadcast, but Phoenix.PubSub dependency is not loaded"} 111 | end 112 | end 113 | def init([{:send, generic_process} | args], state = %{send_to: send_to}) do 114 | init(args, %{state | send_to: [generic_process | send_to] }) 115 | end 116 | def init([{:cast, server_process} | args], state = %{cast_to: cast_to}) do 117 | init(args, %{state | cast_to: [server_process | cast_to] }) 118 | end 119 | def init([{:call, server_process} | args], state = %{call_to: call_to}) do 120 | init(args, %{state | call_to: [server_process | call_to] }) 121 | end 122 | def init([ _unknown | args], state) do 123 | # Skip unknown arg 124 | init(args, state) 125 | end 126 | def init([], state) do 127 | # End of args 128 | {:ok, state} 129 | end 130 | 131 | def handle_event(event, state = %{broadcast_to: broadcast_to, send_to: send_to, cast_to: cast_to, call_to: call_to}) do 132 | # Forward event to all PubSub topics in broadcast_to in a concurrent manner 133 | broadcast_to 134 | |> Enum.reverse 135 | |> Enum.each(fn {server_process, topic} -> PubSub.broadcast(server_process, topic, event) end) 136 | 137 | # Forward event to all generic processes in send_to in a concurrent manner 138 | cast_to 139 | |> Enum.reverse 140 | |> Enum.each(fn generic_process -> Process.send(generic_process, event, [:nosuspend]) end) 141 | 142 | # Forward event to all GenServers in cast_to in a concurrent manner 143 | cast_to 144 | |> Enum.reverse 145 | |> Enum.each(fn server_process -> GenServer.cast(server_process, event) end) 146 | 147 | # Forward event to all GenServers in call_to in a concurrent manner 148 | call_to 149 | |> Enum.reverse 150 | |> Enum.map(fn server_process -> Task.async(GenServer, :call, [server_process, event]) end) 151 | |> Task.yield_many(@backpressure_timeout) 152 | |> Enum.map(fn {task, res} -> 153 | # Shutdown the tasks that did not reply nor exit 154 | res || Task.shutdown(task, :brutal_kill) 155 | end) 156 | 157 | {:ok, state} 158 | end 159 | 160 | def handle_call(:hibernate, state) do 161 | {:ok, :ok, state, :hibernate} 162 | end 163 | 164 | def handle_call({:add_broadcast, pubsub_topic = {_pubsub_server, _topic}}, state = %{broadcast_to: broadcast_to}) do 165 | if Code.ensure_loaded?(PubSub) do 166 | if Enum.any?(broadcast_to, &(&1 == pubsub_topic) ) do 167 | {:ok, :ok, state} 168 | else 169 | {:ok, :ok, %{state | broadcast_to: [pubsub_topic | broadcast_to] } } 170 | end 171 | 172 | else 173 | {:error, "Specified broadcast, but Phoenix.PubSub dependency is not loaded"} 174 | end 175 | end 176 | 177 | def handle_call({:remove_broadcast, pubsub_topic = {_pubsub_server, _topic}}, state = %{broadcast_to: broadcast_to}) do 178 | broadcast_to = broadcast_to |> Enum.filter(&(&1 != pubsub_topic)) 179 | {:ok, :ok, %{state | broadcast_to: broadcast_to } } 180 | end 181 | 182 | def handle_call({:add_cast, server_process}, state = %{cast_to: cast_to}) do 183 | if Enum.any?(cast_to, &(&1 == server_process) ) do 184 | {:ok, :ok, state} 185 | else 186 | {:ok, :ok, %{state | cast_to: [server_process | cast_to] } } 187 | end 188 | end 189 | 190 | def handle_call({:remove_cast, server_process}, state = %{cast_to: cast_to}) do 191 | cast_to = cast_to |> Enum.filter(&(&1 != server_process)) 192 | {:ok, :ok, %{state | cast_to: cast_to } } 193 | end 194 | 195 | def handle_call({:add_call, server_process}, state = %{call_to: call_to}) do 196 | if Enum.any?(call_to, &(&1 == server_process) ) do 197 | {:ok, :ok, state} 198 | else 199 | {:ok, :ok, %{state | call_to: [server_process | call_to] } } 200 | end 201 | end 202 | 203 | def handle_call({:remove_call, server_process}, state = %{call_to: call_to}) do 204 | call_to = call_to |> Enum.filter(&(&1 != server_process)) 205 | {:ok, :ok, %{state | call_to: call_to } } 206 | end 207 | 208 | end -------------------------------------------------------------------------------- /lib/aws/iot/thing_shadow/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Aws.Iot.ThingShadow.Supervisor do 2 | 3 | @moduledoc ~S""" 4 | Module-based supervisor for `ThingShadow.Client`. 5 | Ensures that event handling and dispatch can survive the lifetime of `ThingShadow.Client`. 6 | 7 | You may want to use a module-based supervisor if: 8 | - You need to perform some particular action on supervisor initialization, like setting up an ETS table. 9 | - You want to perform partial hot-code swapping of the tree. For example, if you add or remove children, the module-based supervision will add and remove the new children directly, while dynamic supervision requires the whole tree to be restarted in order to perform such swaps. 10 | """ 11 | 12 | use Supervisor 13 | 14 | @supervision_strategy :rest_for_one 15 | 16 | @doc """ 17 | Starts the supervisor 18 | """ 19 | def start_link(client_name \\ Aws.Iot.ThingShadow.Client, opts \\ []) 20 | def start_link(client_name, opts) do 21 | supervisor_name = name_concat(client_name, "Supervisor") 22 | # To start the supervisor, the init/1 callback will be invoked in the given module, with init_args as its argument 23 | Supervisor.start_link(__MODULE__, [client_name, opts], name: supervisor_name) 24 | end 25 | 26 | @doc """ 27 | Callback invoked to start the supervisor and during hot code upgrades. 28 | """ 29 | def init([client_name, opts]) when is_list(opts) do 30 | mqttc_options_or_app_name = Keyword.get(opts, :mqtt, nil) || Keyword.get(opts, :app_config, :aws_iot) 31 | 32 | event_manager_name = name_concat(client_name, "EventManager") 33 | 34 | # ThingShadow worker must be able to lookup its GenEvent.manager by name, not by pid. 35 | # This is so that when GenEvent.manager is restarted with a new pid, the reference is still valid. 36 | children = [ 37 | worker(GenEvent, [[name: event_manager_name]]), 38 | worker(Aws.Iot.ThingShadow.Client, [event_manager_name, mqttc_options_or_app_name, [name: client_name]]) 39 | ] 40 | 41 | # Init must return a supervisor spec 42 | supervise(children, strategy: @supervision_strategy) 43 | end 44 | 45 | # Handles more cases than `Module.concat/2` 46 | defp name_concat(name1, name2) when is_binary(name2) do 47 | case name1 do 48 | {:via, via_module, name} -> {:via, via_module, "#{name}.#{name2}"} 49 | {:global, name} -> {:global, "#{name}.#{name2}"} 50 | name when is_atom(name) -> :"#{name}.#{name2}" 51 | end 52 | end 53 | defp name_concat(name1, name2) when is_atom(name2) do 54 | name2 = tl Module.split(Module.concat(name1, name2)) 55 | name_concat(name1, name2) 56 | end 57 | 58 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule AwsIot.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :aws_iot, 6 | version: "0.0.1", 7 | build_path: "../../_build", 8 | config_path: "../../config/config.exs", 9 | deps_path: "../../deps", 10 | lockfile: "../../mix.lock", 11 | elixir: "~> 1.2", 12 | build_embedded: Mix.env == :prod, 13 | start_permanent: Mix.env == :prod, 14 | deps: deps] 15 | end 16 | 17 | # Configuration for the OTP application 18 | # 19 | # Type "mix help compile.app" for more information 20 | def application do 21 | [applications: [:logger], 22 | mod: {Aws.Iot, []}] 23 | end 24 | 25 | # Dependencies can be Hex packages: 26 | # 27 | # {:mydep, "~> 0.3.0"} 28 | # 29 | # Or git/path repositories: 30 | # 31 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 32 | # 33 | # To depend on another app inside the umbrella: 34 | # 35 | # {:myapp, in_umbrella: true} 36 | # 37 | # Type "mix help deps" for more examples and options 38 | defp deps do 39 | [ 40 | {:emqttc, github: "heri16/emqttc", branch: "master"}, 41 | {:phoenix_pubsub, "~> 1.0", optional: true} 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/aws_iot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AwsIotTest do 2 | use ExUnit.Case 3 | doctest AwsIot 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------