-
Notifications
You must be signed in to change notification settings - Fork 69
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a way to extend DeviceChannel functionality via Extensions
Allows for specialized extensions on device to report data and interactions safely outside the without affecting the firmware update mechanism. - Geo and Health adapted to the new mechanism. - Product-level settings to enable/disable - Device-level settings to mostly disable
- Loading branch information
1 parent
3843e0b
commit ede38df
Showing
29 changed files
with
781 additions
and
115 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
defmodule NervesHub.Extensions do | ||
@moduledoc """ | ||
An "extension" is an additional piece of functionality that we add onto the | ||
existing connection between the device and the NervesHub service. They are | ||
designed to be less important than firmware updates and requires both client | ||
to report support and the server to enable support. | ||
This is intended to ensure that: | ||
- The service decides when activity should be taken by the device meaning | ||
the fleet of devices will not inadvertently swarm the service with data. | ||
- The service can turn off extensions in various ways to ensure that disruptive | ||
extensions stop being enabled on subsequent connections. | ||
- Use of extensions should have very little chance to disrupt the flow of a | ||
critical firmware update. | ||
""" | ||
|
||
@callback handle_in(event :: String.t(), Phoenix.Channel.payload(), Phoenix.Socket.t()) :: | ||
{:noreply, Phoenix.Socket.t()} | ||
| {:noreply, Phoenix.Socket.t(), timeout() | :hibernate} | ||
| {:reply, Phoenix.Channel.reply(), Phoenix.Socket.t()} | ||
| {:stop, reason :: term(), Phoenix.Socket.t()} | ||
| {:stop, reason :: term(), Phoenix.Channel.reply(), Phoenix.Socket.t()} | ||
|
||
@callback handle_info(msg :: term(), Phoenix.Socket.t()) :: | ||
{:noreply, Phoenix.Socket.t()} | {:stop, reason :: term(), Phoenix.Socket.t()} | ||
|
||
@callback attach(Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} | ||
@callback detach(Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} | ||
|
||
require Logger | ||
|
||
@supported_extensions %{ | ||
health: """ | ||
Reporting of fundamental device metrics, metadata, alarms and more. | ||
Also supports custom metrics. Alarms require an alarm handler to be set. | ||
""", | ||
geo: """ | ||
Reporting of GeoIP information or custom geo-location information sources | ||
you've set up for your device. | ||
""" | ||
} | ||
@type extension() :: :health | :geo | ||
|
||
@doc """ | ||
Get list of supported extensions as atoms with descriptive text. | ||
""" | ||
@spec list() :: %{extension() => String.t()} | ||
def list do | ||
@supported_extensions | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
defmodule NervesHub.Extensions.ExtensionsSetting do | ||
use Ecto.Schema | ||
import Ecto.Changeset | ||
|
||
@primary_key false | ||
embedded_schema do | ||
field(:health, :boolean, default: nil) | ||
field(:geo, :boolean, default: nil) | ||
end | ||
|
||
def changeset(setting, params) do | ||
setting | ||
|> cast(params, [:health, :geo]) | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
defmodule NervesHub.Extensions.Geo do | ||
@behaviour NervesHub.Extensions | ||
|
||
alias NervesHub.Devices | ||
|
||
@impl NervesHub.Extensions | ||
def attach(socket) do | ||
extension_config = Application.get_env(:nerves_hub, :extension_config) | ||
geo_interval = get_in(extension_config, [:geo, :interval_minutes]) || 0 | ||
|
||
send(self(), {__MODULE__, :location_request}) | ||
|
||
socket = | ||
if geo_interval > 0 do | ||
timer = | ||
geo_interval | ||
|> :timer.minutes() | ||
|> :timer.send_interval({__MODULE__, :location_request}) | ||
|
||
socket | ||
|> Phoenix.Socket.assign(:geo_timer, timer) | ||
|> Phoenix.Socket.assign(:geo_interval, geo_interval) | ||
else | ||
socket | ||
end | ||
|
||
{:noreply, socket} | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def detach(socket) do | ||
_ = if socket.assigns[:geo_timer], do: :timer.cancel(socket.assigns.geo_timer) | ||
{:noreply, Phoenix.Socket.assign(socket, :geo_timer, nil)} | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def handle_in("location:update", location, %{assigns: %{device: device}} = socket) do | ||
metadata = Map.put(device.connection_metadata, "location", location) | ||
|
||
{:ok, device} = Devices.update_device(device, %{connection_metadata: metadata}) | ||
|
||
_ = | ||
NervesHubWeb.DeviceEndpoint.broadcast( | ||
"device:#{device.identifier}:internal", | ||
"location:updated", | ||
location | ||
) | ||
|
||
{:noreply, Phoenix.Socket.assign(socket, :device, device)} | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def handle_info(:location_request, socket) do | ||
Phoenix.Channel.push(socket, "geo:location:request", %{}) | ||
{:noreply, socket} | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
defmodule NervesHub.Extensions.Health do | ||
@behaviour NervesHub.Extensions | ||
|
||
alias NervesHub.Devices | ||
alias NervesHub.Devices.Metrics | ||
|
||
require Logger | ||
|
||
# @impl NervesHub.Extensions | ||
def init(socket) do | ||
# Allow DB settings? | ||
socket | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def attach(socket) do | ||
extension_config = Application.get_env(:nerves_hub, :extension_config, []) | ||
|
||
health_interval = | ||
case get_in(extension_config, [:health, :interval_minutes]) do | ||
i when is_integer(i) -> i | ||
_ -> 60 | ||
end | ||
|
||
send(self(), {__MODULE__, :check}) | ||
|
||
socket = | ||
if health_interval > 0 do | ||
timer = | ||
health_interval | ||
|> :timer.minutes() | ||
|> :timer.send_interval({__MODULE__, :check}) | ||
|
||
socket | ||
|> Phoenix.Socket.assign(:health_interval, health_interval) | ||
|> Phoenix.Socket.assign(:health_timer, timer) | ||
else | ||
socket | ||
end | ||
|
||
{:noreply, socket} | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def detach(socket) do | ||
_ = if socket.assigns[:health_timer], do: :timer.cancel(socket.assigns.health_timer) | ||
{:noreply, Phoenix.Socket.assign(socket, :health_timer, nil)} | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def handle_in("report", %{"value" => device_status}, socket) do | ||
device_meta = | ||
for {key, val} <- Map.from_struct(socket.assigns.device.firmware_metadata), | ||
into: %{}, | ||
do: {to_string(key), to_string(val)} | ||
|
||
# Separate metrics from health report to store in metrics table | ||
metrics = device_status["metrics"] | ||
|
||
health_report = | ||
device_status | ||
|> Map.delete("metrics") | ||
|> Map.put("metadata", Map.merge(device_status["metadata"], device_meta)) | ||
|
||
device_health = %{"device_id" => socket.assigns.device.id, "data" => health_report} | ||
|
||
with {:health_report, {:ok, _}} <- | ||
{:health_report, Devices.save_device_health(device_health)}, | ||
{:metrics_report, {count, _}} when count >= 0 <- | ||
{:metrics_report, Metrics.save_metrics(socket.assigns.device.id, metrics)} do | ||
device_internal_broadcast!(socket.assigns.device, "health_check_report", %{}) | ||
else | ||
{:health_report, {:error, err}} -> | ||
Logger.warning("Failed to save health check data: #{inspect(err)}") | ||
log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save health check data.") | ||
|
||
{:metrics_report, {:error, err}} -> | ||
Logger.warning("Failed to save metrics: #{inspect(err)}") | ||
log_to_sentry(socket.assigns.device, "[DeviceChannel] Failed to save metrics.") | ||
end | ||
|
||
{:noreply, socket} | ||
end | ||
|
||
@impl NervesHub.Extensions | ||
def handle_info(:check, socket) do | ||
Phoenix.Channel.push(socket, "health:check", %{}) | ||
{:noreply, socket} | ||
end | ||
|
||
defp device_internal_broadcast!(device, event, payload) do | ||
topic = "device:#{device.identifier}:extensions" | ||
NervesHubWeb.DeviceEndpoint.broadcast_from!(self(), topic, event, payload) | ||
end | ||
|
||
defp log_to_sentry(device, msg_or_ex, extra \\ %{}) do | ||
Sentry.Context.set_tags_context(%{ | ||
device_identifier: device.identifier, | ||
device_id: device.id, | ||
product_id: device.product_id, | ||
org_id: device.org_id | ||
}) | ||
|
||
_ = | ||
if is_exception(msg_or_ex) do | ||
Sentry.capture_exception(msg_or_ex, extra: extra, result: :none) | ||
else | ||
Sentry.capture_message(msg_or_ex, extra: extra, result: :none) | ||
end | ||
|
||
:ok | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.