Skip to content

Commit 870613a

Browse files
committed
Start support for AWS IoT setup
Initial support for an MQTT and message queue setup for device connections as an alternative to websockets
1 parent 6d99d21 commit 870613a

File tree

9 files changed

+479
-0
lines changed

9 files changed

+479
-0
lines changed

config/dev.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,38 @@ else
9393
end
9494

9595
config :sentry, environment_name: "developent"
96+
97+
broker_opts = [
98+
name: NervesHub.AWSIoT.PintBroker,
99+
rules: [{"nh/device_messages", &Broadway.test_message(:nerves_hub_iot_messages, &1.payload)}],
100+
on_connect: fn client_id ->
101+
payload = %{clientId: client_id, eventType: :connected}
102+
Broadway.test_message(:nerves_hub_iot_messages, Jason.encode!(payload))
103+
end,
104+
on_disconnect: fn client_id ->
105+
payload = %{
106+
clientId: client_id,
107+
eventType: :disconnected,
108+
disconnectReason: "CONNECTION_LOST"
109+
}
110+
111+
Broadway.test_message(:nerves_hub_iot_messages, Jason.encode!(payload))
112+
end
113+
]
114+
115+
config :nerves_hub, NervesHub.AWSIoT,
116+
# Run a PintBroker for local process and/or device connections
117+
local_broker: {PintBroker, broker_opts},
118+
queues: [
119+
[
120+
name: :nerves_hub_iot_messages,
121+
producer: [
122+
module: {Broadway.DummyProducer, []}
123+
# To test fetching from development queues registered with AWS, use the producer
124+
# below. You may need to set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY ¬
125+
# module: {BroadwaySQS.Producer, queue_url: "nerves-hub-iot-messages"}
126+
],
127+
processors: [default: []],
128+
batchers: [default: [batch_size: 10, batch_timeout: 2000]]
129+
]
130+
]

config/release.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,23 @@ if nerves_hub_app in ["all", "device"] do
102102
certfile: "/etc/ssl/#{host}.pem",
103103
cacertfile: "/etc/ssl/ca.pem"
104104
]
105+
106+
if System.get_env("AWS_IOT_ENABLED") in ["1", "true", "t"] do
107+
config :nerves_hub, NervesHub.AWSIoT,
108+
queues: [
109+
[
110+
# AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set
111+
name: :nerves_hub_iot_messages,
112+
producer: [
113+
module:
114+
{BroadwaySQS.Producer,
115+
queue_url: System.get_env("AWS_IOT_SQS_QUEUE", "nerves-hub-iot-messages")}
116+
],
117+
processors: [default: []],
118+
batchers: [default: [batch_size: 10, batch_timeout: 2000]]
119+
]
120+
]
121+
end
105122
end
106123

107124
config :sentry,

config/test.exs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,36 @@ config :opentelemetry, :processors,
8282
config :sentry,
8383
environment_name: :test,
8484
included_environments: []
85+
86+
## AWS IoT
87+
broker_opts = [
88+
name: NervesHub.AWSIoT.PintBroker,
89+
rules: [{"nh/device_messages", &Broadway.test_message(:nerves_hub_iot_messages, &1.payload)}],
90+
on_connect: fn client_id ->
91+
payload = %{clientId: client_id, eventType: :connected}
92+
Broadway.test_message(:nerves_hub_iot_messages, Jason.encode!(payload))
93+
end,
94+
on_disconnect: fn client_id ->
95+
payload = %{
96+
clientId: client_id,
97+
eventType: :disconnected,
98+
disconnectReason: "CONNECTION_LOST"
99+
}
100+
101+
Broadway.test_message(:nerves_hub_iot_messages, Jason.encode!(payload))
102+
end
103+
]
104+
105+
config :nerves_hub, NervesHub.AWSIoT,
106+
# Use PintBroker for local device connections in tests
107+
local_broker: {PintBroker, broker_opts},
108+
queues: [
109+
[
110+
name: :nerves_hub_iot_messages,
111+
producer: [
112+
module: {Broadway.DummyProducer, []}
113+
],
114+
processors: [default: []],
115+
batchers: [default: [batch_size: 10, batch_timeout: 2000]]
116+
]
117+
]

lib/nerves_hub/application.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ defmodule NervesHub.Application do
5151

5252
defp endpoints(:test) do
5353
[
54+
NervesHub.AWSIoT,
5455
NervesHub.Devices.Supervisor,
5556
NervesHubWeb.DeviceEndpoint,
5657
NervesHubWeb.Endpoint
@@ -65,6 +66,7 @@ defmodule NervesHub.Application do
6566
case Application.get_env(:nerves_hub, :app) do
6667
"all" ->
6768
[
69+
NervesHub.AWSIoT,
6870
NervesHub.Deployments.Supervisor,
6971
NervesHub.Devices.Supervisor,
7072
NervesHubWeb.DeviceEndpoint,
@@ -74,6 +76,7 @@ defmodule NervesHub.Application do
7476

7577
"device" ->
7678
[
79+
NervesHub.AWSIoT,
7780
NervesHub.Deployments.Supervisor,
7881
NervesHub.Devices.Supervisor,
7982
NervesHubWeb.DeviceEndpoint,

lib/nerves_hub/aws_iot.ex

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
defmodule NervesHub.AWSIoT do
2+
@moduledoc """
3+
Support for common AWS IOT infrastructure including MQTT and SQS
4+
5+
Requires `:queues` to be defined in the application config or
6+
the supervisor is simply ignored
7+
8+
See docs.nerves-hub.org for a general overview of the architecture
9+
"""
10+
use Supervisor
11+
12+
@type opt :: {:queues, [keyword()]}
13+
@spec start_link([opt]) :: Supervisor.on_start()
14+
def start_link(opts) do
15+
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
16+
end
17+
18+
@impl Supervisor
19+
def init(opts) do
20+
opts =
21+
Application.get_env(:nerves_hub, __MODULE__, [])
22+
|> Keyword.merge(opts)
23+
24+
case opts[:queues] do
25+
queues when is_list(queues) and length(queues) > 0 ->
26+
children =
27+
Enum.map(queues, &{__MODULE__.SQS, &1})
28+
|> maybe_add_local_broker(opts)
29+
30+
Supervisor.init(children, strategy: :one_for_one)
31+
32+
_ ->
33+
:ignore
34+
end
35+
end
36+
37+
defp maybe_add_local_broker(children, opts) do
38+
if broker_spec = opts[:local_broker] do
39+
[broker_spec | children]
40+
else
41+
children
42+
end
43+
end
44+
45+
if Application.compile_env(:nerves_hub, [__MODULE__, :local_broker], false) do
46+
def publish(serial, event, payload) do
47+
data = Jason.encode!(%{event: event, payload: payload})
48+
PintBroker.publish(__MODULE__.PintBroker, "nh/#{serial}", data)
49+
end
50+
else
51+
def publish(serial, event, payload) do
52+
# TODO: Topic and data may change soon
53+
# Stubbing out initial idea here for now
54+
data = %{event: event, payload: payload}
55+
topic = "/topics/nh/#{serial}"
56+
57+
ExAws.Operation.JSON.new(:iot_data, %{path: topic, data: data})
58+
|> ExAws.request()
59+
end
60+
end
61+
62+
defmodule SQS do
63+
@moduledoc """
64+
Consumer for AWS SQS messages
65+
66+
This is the ingestion point of devices coming from the MQTT
67+
broker. A message from a device must include the `"identifier"`
68+
key either in the payload or pulled from the topic via the
69+
AWS IoT rule that forwards to the queue.
70+
71+
The system must also be setup with a rule to forward [AWS Lifecycle
72+
events](https://docs.aws.amazon.com/iot/latest/developerguide/life-cycle-events.html)
73+
to a queue for tracking device online/offline presence
74+
75+
Right now, all configured queues are handled by this module.
76+
In the future, we may want to separate handling for each
77+
queue in it's own module.
78+
"""
79+
use Broadway
80+
81+
alias Broadway.Message
82+
alias NervesHub.Devices
83+
alias NervesHub.Devices.DeviceLink
84+
85+
require Logger
86+
87+
def start_link(opts), do: Broadway.start_link(__MODULE__, opts)
88+
89+
@impl Broadway
90+
def handle_message(_processor, %{data: raw} = msg, _context) do
91+
case Jason.decode(raw) do
92+
{:ok, data} ->
93+
Message.put_data(msg, data)
94+
|> process_message()
95+
96+
_ ->
97+
Message.failed(msg, :malformed)
98+
end
99+
end
100+
101+
@impl Broadway
102+
def handle_batch(_batcher, messages, batch_info, _context) do
103+
Logger.debug("[SQS] Handled #{inspect(batch_info.size)}")
104+
messages
105+
end
106+
107+
defp process_message(%{data: %{"eventType" => "connected"} = data} = msg) do
108+
# TODO: Maybe use more info from the connection?
109+
# Example payload of AWS lifecycle connected event
110+
# principalIdentifier is a SHA256 fingerprint of the certificate that
111+
# is Base16 encoded
112+
# {
113+
# "clientId": "186b5",
114+
# "timestamp": 1573002230757,
115+
# "eventType": "connected",
116+
# "sessionIdentifier": "a4666d2a7d844ae4ac5d7b38c9cb7967",
117+
# "principalIdentifier": "12345678901234567890123456789012",
118+
# "ipAddress": "192.0.2.0",
119+
# "versionNumber": 0
120+
# }
121+
122+
with {:ok, device} <- Devices.get_by_identifier(data["clientId"]),
123+
push_cb = &NervesHub.AWSIoT.publish(device.identifier, &1, &2),
124+
# TODO: Adjust DeviceLink for this since we won't have
125+
# metadata at this point
126+
{:ok, _device} <- DeviceLink.connect(device, push_cb, %{}) do
127+
msg
128+
else
129+
_ ->
130+
Message.failed(msg, :unknown_device)
131+
end
132+
end
133+
134+
defp process_message(%{data: %{"eventType" => "disconnected"} = data} = msg) do
135+
# TODO: Maybe use more of the disconnect data?
136+
# Example payload of AWS lifecyle disconnect event
137+
# {
138+
# "clientId": "186b5",
139+
# "timestamp": 1573002340451,
140+
# "eventType": "disconnected",
141+
# "sessionIdentifier": "a4666d2a7d844ae4ac5d7b38c9cb7967",
142+
# "principalIdentifier": "12345678901234567890123456789012",
143+
# "clientInitiatedDisconnect": true,
144+
# "disconnectReason": "CLIENT_INITIATED_DISCONNECT",
145+
# "versionNumber": 0
146+
# }
147+
with {:ok, device} <- Devices.get_by_identifier(data["clientId"]) do
148+
# TODO: Update DeviceLink.disconect with reason and track?
149+
Logger.debug(
150+
"[AWS IoT] device #{data["clientId"]} disconnected: #{data["disconnectReason"]}"
151+
)
152+
153+
DeviceLink.disconnect(device)
154+
end
155+
156+
msg
157+
end
158+
159+
defp process_message(msg) do
160+
# TODO: Track unhandled msg
161+
msg
162+
end
163+
end
164+
end

mix.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ defmodule NervesHub.MixProject do
5959
{:base62, "~> 1.2"},
6060
{:bcrypt_elixir, "~> 3.0"},
6161
{:circular_buffer, "~> 0.4.1"},
62+
{:broadway_sqs, "~> 0.7"},
6263
{:comeonin, "~> 5.3"},
6364
{:cowboy, "~> 2.0", override: true},
6465
{:crontab, "~> 1.1"},
@@ -92,6 +93,7 @@ defmodule NervesHub.MixProject do
9293
{:phoenix_pubsub, "~> 2.0"},
9394
{:phoenix_swoosh, "~> 1.0"},
9495
{:phoenix_view, "~> 2.0"},
96+
{:pint_broker, "~> 1.0", only: [:dev, :test]},
9597
{:plug, "~> 1.7"},
9698
{:plug_cowboy, "~> 2.1"},
9799
{:postgrex, "~> 0.14"},

0 commit comments

Comments
 (0)