Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add device extensions support #1479

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

Add device extensions support #1479

wants to merge 1 commit into from

Conversation

jjcarstens
Copy link
Collaborator

@jjcarstens jjcarstens commented Sep 1, 2024

Start of device extensions which allow for specialized functionality on device to report data and interactions safely outside the update mechanism.

Requires nerves-hub/nerves_hub_link#228

Based on conversations the last while, these are some of the core design decisions so far:

  • NervesHub selectively tells a device what extensions to enable
  • Extensions are off by default on device
  • Extensions should have no affect on socket connectivity or ability to update a device

With that in mind, this is the basic communication cycle for supporting extensions on device.

sequenceDiagram
    participant Device as Device (NHL)
    participant NH as NervesHub

    Device ->> NH: Connect
    NH ->> NH: Check `extensions_allowed?`
    NH ->> Device: Send "extensions:get"
    Device ->> NH: Join "extensions" channel with [%{<extension> => <version>}]
    NH ->> NH: Determine extensions to enable based on report
    NH ->> Device: Send "extensions:enable" to with list of extensions to enable

    loop For each extension to enable
        Device ->> NH: Report "<extension_name>:<event>" where event is enabled or error
    end
Loading

TODO: All the bits to selectively decide what extensions can be enabled for a device

Copy link
Contributor

@lawik lawik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a good approach to me. Want me to flesh out the allow mechanisms as a branch off of this branch or something?

Or we merge this once we tidy up the dbg and TODO notes?

lib/nerves_hub_web/channels/device_channel.ex Outdated Show resolved Hide resolved
@lawik lawik marked this pull request as ready for review October 28, 2024 15:57
@lawik
Copy link
Contributor

lawik commented Oct 28, 2024

Fixing the rebase. But ready for review @joshk and I've cut anything that would cause legacy for the yet-unreleased health and geo features. So two env vars have changed to match. Messaging internally has changed. Message to the link-side features have been aligned to all do similar stuff.

@lawik lawik force-pushed the features-channel branch 2 times, most recently from 8ea0780 to 1db8da2 Compare October 28, 2024 20:17
@lawik
Copy link
Contributor

lawik commented Nov 11, 2024

I need to manually test this locally to make sure everything works correctly. But would love review @joshk so when I've tried it properly we can move this and the matching nerves_hub_link along.

@jjcarstens jjcarstens force-pushed the features-channel branch 2 times, most recently from b8e62e9 to 5de29ff Compare November 11, 2024 22:13
@lawik
Copy link
Contributor

lawik commented Nov 12, 2024

@jjcarstens was this just a rebase?

@lawik
Copy link
Contributor

lawik commented Nov 12, 2024

Running this and nerves_hub_link locally, catching a bunch of trickiness :)

@lawik lawik marked this pull request as draft November 19, 2024 13:01
@lawik
Copy link
Contributor

lawik commented Nov 19, 2024

Marking as draft while I churn through renaming and so on.

@lawik lawik force-pushed the features-channel branch 2 times, most recently from 410d841 to e2c441d Compare November 19, 2024 14:31
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
@lawik lawik removed the request for review from oestrich November 19, 2024 14:56
@lawik lawik marked this pull request as ready for review November 19, 2024 14:56
@lawik lawik changed the title Add device features support Add device extensions support Nov 19, 2024
@lawik
Copy link
Contributor

lawik commented Nov 19, 2024

Re-wrote Jon's words above to be all about EXTENSIONS! 🤘🏻 👀 🤘🏻

I've renamed all the things. The sibling nerves_hub_link PR seems healthy and solid. I put one test on pending because it is just messy to update for now.

I think everything is good to go. Or at least ready to be picked over :D

@lawik
Copy link
Contributor

lawik commented Nov 19, 2024

So paging @joshk and @jjcarstens for reviewing :)

@@ -1383,4 +1383,33 @@ defmodule NervesHub.Devices do
"pending"
end
end

def enable_extension_setting(%Device{} = device, extension_string) do
device = Repo.get(Device, device.id)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we fetching the device if we are requiring it as an arg?


Device.changeset(device, %{"extensions" => %{extension_string => true}})
|> Repo.update()
|> tap(fn _ ->
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we will need to check that the update was successful before broadcasting, otherwise we will be out of sync with the setting

def enable_extension_setting(%Device{} = device, extension_string) do
device = Repo.get(Device, device.id)

Device.changeset(device, %{"extensions" => %{extension_string => true}})
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually work if I have multiple extensions? To me it looks like it will completely overwrite all extension settings with just this one?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not, embeds_one raises by default on replace. I think an on_replace: :update will work here.

Comment on lines +33 to +42
@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.
"""
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a behavior module, I feel like we should keep as much of this in the extension as possible. Maybe we need a description callback for the module to define it?

case result do
{:ok, _pf} ->
# reload extensions
assign(socket, :extensions, Extensions.list())
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extensions.list() is static. Do we need to reload it? In fact, we probably don't even need to hold it in the assigns and just render with the view. Save some memory

@@ -94,7 +95,7 @@ defmodule NervesHubWeb.DeviceSocket do
|> Devices.get_device_certificate_by_x509()
|> case do
{:ok, %{device: %Device{} = device}} ->
socket_and_assigns(socket, device)
socket_and_assigns(socket, Devices.preload_product(device))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to include this preload in a more effect way? Like with getting the device certificate? I know Josh did a lot of work optimizing preloads so I'm hesitant to put such a small function in without thinking through

@@ -34,6 +34,7 @@ defmodule NervesHubWeb.Live.Devices.DeviceHealth do

if connected?(socket) do
socket.endpoint.subscribe("device:#{device.identifier}:internal")
socket.endpoint.subscribe("device:#{device.identifier}:extensions")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we doing with this subscription here?

Comment on lines +30 to +35
product_extensions
|> Map.from_struct()
|> Enum.filter(fn {extension, enabled?} ->
enabled? == true and Map.get(device_extensions, extension) != false
end)
|> Enum.map(&elem(&1, 0))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
product_extensions
|> Map.from_struct()
|> Enum.filter(fn {extension, enabled?} ->
enabled? == true and Map.get(device_extensions, extension) != false
end)
|> Enum.map(&elem(&1, 0))
for {extension, true} <- Map.from_struct(product_extensions),
{^extension, device_enabled?} <- Map.from_struct(device_extensions,
device_enabled? != false,
do: extension

Slight optimization. Less looping, more direct filtering. Gets you list of extensions only allowed at product and device

Copy link
Contributor

@nshoes nshoes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good so far; I left some comments, questions, and concerns.

def enable_extension_setting(%Device{} = device, extension_string) do
device = Repo.get(Device, device.id)

Device.changeset(device, %{"extensions" => %{extension_string => true}})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not, embeds_one raises by default on replace. I think an on_replace: :update will work here.

@@ -87,6 +87,8 @@ config :nerves_hub, NervesHub.SwooshMailer, adapter: Swoosh.Adapters.Test

config :nerves_hub, NervesHub.RateLimit, limit: 100

config :sentry, environment_name: :test
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this do? Were we reporting errors to Sentry from tests before? 😨

Comment on lines +9 to +13
# @impl NervesHub.Extensions
def init(socket) do
# Allow DB settings?
socket
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cruft?


health_interval =
case get_in(extension_config, [:health, :interval_minutes]) do
i when is_integer(i) -> i
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed if we're calling String.to_integer() in runtime.exs?

Comment on lines +273 to +294
def enable_extension_setting(%Product{} = product, extension_string) do
product = Repo.get(Product, product.id)

Product.changeset(product, %{"extensions" => %{extension_string => true}})
|> Repo.update()
|> tap(fn _ ->
topic = "product:#{product.id}:extensions"

NervesHubWeb.DeviceEndpoint.broadcast(topic, "attach", %{"extensions" => [extension_string]})
end)
end

def disable_extension_setting(%Product{} = product, extension_string) do
product = Repo.get(Product, product.id)

Product.changeset(product, %{"extensions" => %{extension_string => false}})
|> Repo.update()
|> tap(fn _ ->
topic = "product:#{product.id}:extensions"

NervesHubWeb.DeviceEndpoint.broadcast(topic, "detach", %{"extensions" => [extension_string]})
end)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on refactoring this into a toggle_extension_setting vs handling each condition explicitly?

end

describe "device location" do
# This test needs rework due to the extensions mechanism
@tag :pending
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you did some work on this test, is anything more needed?

|> assign(:reply, reply)

{:ok, socket}
end

@impl true
def handle_message("device", "extensions:get", _message, socket) do
{:ok, socket}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we do nothing here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants