├── .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 |
--------------------------------------------------------------------------------