Skip to content

Commit

Permalink
Merge pull request #37 from grisp/SEA-298/extend-protocol-to-update-sw
Browse files Browse the repository at this point in the history
SEA-298 Extend protocol to update sw (with grisp_updater)
  • Loading branch information
sylane authored Oct 10, 2024
2 parents 499da87 + 279942a commit 7521fec
Show file tree
Hide file tree
Showing 8 changed files with 432 additions and 54 deletions.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# grisp_connect

GRiSP.io Client Library for GRiSP
GRiSP.io Client Library for GRiSP. This library enables secure communication
between your GRiSP2 board and the [GRiSP.io](https://grisp.io) services using
Mutual TLS (mTLS). To get started, add this application as a dependency in your
GRiSP2 project.

⚠️ **Note:** If you plan to use the API calls related to `grisp_updater_grisp2`,
make sure to add `grisp_updater_grisp2` as a dependency in your project as well.

## Table of content

Expand Down Expand Up @@ -28,10 +34,6 @@ GRiSP.io Client Library for GRiSP
- [Development on GRiSP Hardware](#development-on-grisp-hardware)
- [Production on GRiSP Hardware](#production-on-grisp-hardware)


Add this application as a dependency in your GRiSP2 project.
Your board will connect securely using mTLS to the [GRiSP.io](https://grisp.io) services.

## Usage

### Option 1. Use the `rebar3_grisp` plugin
Expand Down
145 changes: 145 additions & 0 deletions docs/grisp_connect_api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# UI Websocket API

**Table Of Contents**
- [UI Websocket API](#ui-websocket-api)
- [Backend API](#backend-api)
- [Requests](#requests)
- [Notifications](#notifications)
- [Error Codes](#error-codes)
- [Default error codes](#default-error-codes)
- [Custom error codes](#custom-error-codes)

We use [jsonrpc](https://www.jsonrpc.org) 2.0 between frontend and backend.

⚠️ **Note:** If you plan to use the API calls related to `grisp_updater`, make
sure to add `grisp_updater_grisp2` as a dependency in your project as well.

## Backend API

### Requests

</p>
</details>
<details><summary><i>Get - partition_state</i></summary>
<p>

Retrieves the current state of the system’s partition, indicating whether the
system requires a reboot, needs validation, or is running an old partition
with no updates pending. This can be used to check if the system is running a
valid system, or has an update pending.

**`params`:**
| key (required *) | value | description |
| ----------------- | -------- | ------------------- |
| `"type"` * | string | `"partition_state"` |

**`result`**: JSON Object

| key | value | type | description |
|-----------------|-----------|----------|----------------------------------------------------|
| state | string | required | `"old"`, `"old_no_update"`, `"new"`, `"unknown"` |
| message | string | required | Message describing the current state of the system |
| action_required | boolean | required | Indicates whether any action is required (e.g., reboot, validation). |

Meaning of the state:

| key | description |
|-------------------|--------------------------------------------------------------------------------------------|
| `"new"` | The system has booted into a new partition. Validation is required to finalize the update. |
| `"old"` | Current partition is old. A reboot is required to load the new partition. |
| `"old_no_update"` | There is no update pending. The system is running the old partition. |
| `"unknown"` | The current partition state does not match any of the previous described states. |

**`error`**:

| Error Content | When it Happens |
| ----------------------------------------------------| -------------------------------------- |
| `{code: -10, message: "grisp_updater_unavailable"}` | Grisp updater app is not running |

<details><summary><i>Post - Start an update</i></summary>
<p>

Triggers grisp_updater to install an update from the given URL.

**`params`:**
| key (required *) | value | description |
| ----------------- | -------- | -------------------------- |
| `"type"` * | string | `"start_update"` |
| `"url"` * | [string] | URL to the code repository |

**`result`**: `"ok"`

**`error`**:

| Error Content | When it Happens |
| ----------------------------------------------------| -------------------------------- |
| `{code: -10, message: "grisp_updater_unavailable"}` | Grisp updater app is not running |
| `{code: -11, message: "already_updating"}` | An update is already happening |
| `{code: -12, message: "boot_system_not_validated"}` | The board rebooted after an update and needs validation |

</p>
</details>

<details><summary><i>Post - Validate an update</i></summary>
<p>

Validates the current booted partition. This can only be done after an update was installed and a reboot occurred.
This request sets the current partition as permanent in the bootloader if it is not.
If the new partition is not validated, from the next reboot, the bootloader will load the previous one.
This should only be called if the new software is functioning as expected.

**`params`:**
| key (required *) | value | description |
| ----------------- | -------- | -------------------------- |
| `"type"` * | string | `"validate"` |

**`result`**: `"ok"`

**`error`**:

| Error Content | When it Happens |
| ----------------------------------------------------| -------------------------------- |
| `{code: -13, message: "validate_from_unbooted", data: 0}` | The current partition N cannot be validated |

</p>
</details>

### Notifications

<details><summary><code>update</code> <code>{"type":"software_update_event"}</code> - notify the current progess of grisp_updater </summary>
<p>

**`params`:**
| key | value | type | description |
|---------------|---------------------------------------------|----------|--------------------------------------|
|`"type"` | `"software_update_event"` | required | |
|`"event_type"` | `"progress"` `"warning"` `"error"` `"done"` | required | |
|`"message"` | integer | optional | expected in case of warning or error |
|`"reason"` | integer | optional | expected in case of warning or error |
|`"percentage"` | integer | optional | expected in case of progress or error|

</p>
</details>

## Error Codes

### Default error codes

| code | message | meaning |
|---------|------------------|--------------------------------------------------|
|-32700 | Parse error | Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. |
|-32600 | Invalid Request | The JSON sent is not a valid Request object. |
|-32601 | Method not found | The method does not exist / is not available.|
|-32602 | Invalid params | Invalid method parameter(s). |
|-32603 | Internal error | Internal JSON-RPC error. |

### Custom error codes

Additionally to the default jsonrpc error codes the following codes will be returned.

|code | message | meaning |
|---|---|---|
| -1 | `"device not linked"` | device can't be used without being linked to a registered user |
| -2 | `"token expired"` | token is expired |
| -3 | `"device already linked"` | device needs to be unlinked first via UI |
| -4 | `"invalid token"` | token is e.g. not orderly encoded |
181 changes: 158 additions & 23 deletions src/grisp_connect_api.erl
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
%% @doc Librabry module containing the jsonrpc API logic
%% @doc Library module containing the jsonrpc API logic
-module(grisp_connect_api).

-export([request/3]).
-export([notify/3]).
-export([handle_msg/1]).

-include_lib("kernel/include/logger.hrl").

%--- Macros --------------------------------------------------------------------
-define(method_get, <<"get">>).
-define(method_post, <<"post">>).
-define(method_patch, <<"patch">>).
-define(method_delete, <<"delete">>).

%--- API -----------------------------------------------------------------------

% #doc Assembles a jsonrpc request and its uuid
-spec request(Method :: atom() | binary(),
Type :: atom() | binary(),
Params :: map()) -> {ID :: binary(), Encoded :: binary()}.
request(Method, Type, Params) ->
ID = id(),
Rpc = {request, Method, maps:put(type, Type, Params), ID},
Encoded = grisp_connect_jsonrpc:encode(Rpc),
{ID, Encoded}.

% #doc Assembles a jsonrpc notification
-spec notify(Method :: atom() | binary(),
Type :: atom() | binary(),
Params :: map()) -> Encoded :: binary().
notify(Method, Type, Params) ->
Rpc = {notification, Method, maps:put(type, Type, Params)},
grisp_connect_jsonrpc:encode(Rpc).

% @doc Indentifies if the message is a request or a reply to a previous request.
% In case it was a request, returns the reply to be sent to the peer.
% In case it was a response, returns the parsed ID and content to be handled by
% the caller.
-spec handle_msg(JSON :: binary()) ->
{send_response, Response :: binary()} |
{handle_response, ID :: binary(), {ok, Result :: map()} | {error, atom()}}.
handle_msg(JSON) ->
JSON_RPC = grisp_connect_jsonrpc:decode(JSON),
handle_jsonrpc(JSON_RPC).
Expand All @@ -27,7 +53,8 @@ handle_jsonrpc({single, Rpc}) ->

handle_rpc_messages([], Replies) -> lists:reverse(Replies);
handle_rpc_messages([{request, M, Params, ID} | Batch], Replies)
when M == <<"post">> ->
when M == ?method_post;
M == ?method_get ->
handle_rpc_messages(Batch, [handle_request(M, Params, ID) | Replies]);
handle_rpc_messages([{result, _, _} = Res| Batch], Replies) ->
handle_rpc_messages(Batch, [handle_response(Res)| Replies]);
Expand All @@ -39,12 +66,56 @@ handle_rpc_messages([{internal_error, _, _} = E | Batch], Replies) ->
handle_rpc_messages(Batch,
[grisp_connect_jsonrpc:format_error(E)| Replies]).

handle_request(<<"post">>, #{type := <<"flash">>} = Params, ID) ->
Led = maps:get(led, Params, 1),
Color = maps:get(color, Params, red),
{result, flash(Led, Color), ID};
handle_request(?method_get, #{type := <<"partition_state">>} = _Params, ID) ->
Info = get_partition_info(),
Reply = case Info of
#{state := _State,
message := _Msg,
action_required := _ActionRequired} = Response ->
{result, Response, ID};
{error, Reason} ->
ReasonBinary = iolist_to_binary(io_lib:format("~p", [Reason])),
grisp_connect_jsonrpc:format_error({internal_error, ReasonBinary, ID})
end,
{send_response, grisp_connect_jsonrpc:encode(Reply)};
handle_request(?method_post, #{type := <<"start_update">>} = Params, ID) ->
try
URL = maps:get(url, Params),
Reply = case start_update(URL) of
{error, grisp_updater_unavailable} ->
{error, -10, grisp_updater_unavailable, undefined, ID};
{error, already_updating} ->
{error, -11, already_updating, undefined, ID};
{error, boot_system_not_validated} ->
{error, -12, boot_system_not_validated, undefined, ID};
{error, Reason} ->
ReasonBinary = iolist_to_binary(io_lib:format("~p", [Reason])),
grisp_connect_jsonrpc:format_error({internal_error, ReasonBinary, ID});
ok ->
{result, ok, ID}
end,
{send_response, grisp_connect_jsonrpc:encode(Reply)}
catch
throw:bad_key ->
{send_response,
grisp_connect_jsonrpc:format_error(
{internal_error, invalid_params, ID})}
end;
handle_request(?method_post, #{type := <<"validate">>}, ID) ->
Reply = case grisp_updater:validate() of
{error, {validate_from_unbooted, PartitionIndex}} ->
{error, -13, validate_from_unbooted, PartitionIndex, ID};
{error, Reason} ->
ReasonBinary = iolist_to_binary(io_lib:format("~p", [Reason])),
grisp_connect_jsonrpc:format_error({internal_error, ReasonBinary, ID});
ok ->
{result, ok, ID}
end,
{send_response, grisp_connect_jsonrpc:encode(Reply)};
handle_request(_, _, ID) ->
grisp_connect_jsonrpc:format_error({internal_error, method_not_found, ID}).
Error = {internal_error, method_not_found, ID},
FormattedError = grisp_connect_jsonrpc:format_error(Error),
{send_response, grisp_connect_jsonrpc:encode(FormattedError)}.

handle_response(Response) ->
{ID, Reply} = case Response of
Expand All @@ -53,22 +124,86 @@ handle_response(Response) ->
{error, Code, _Message, _Data, ID0} ->
{ID0, {error, error_atom(Code)}}
end,
{response, ID, Reply}.

flash(Led, Color) ->
spawn(fun() ->
?LOG_NOTICE("Flash from Seawater!~n"),
grisp_led:color(Led, Color),
timer:sleep(100),
grisp_led:off(Led)
end),
ok.

error_atom(-1) -> device_not_linked;
error_atom(-2) -> token_expired;
error_atom(-3) -> device_already_linked;
error_atom(-4) -> invalid_token;
error_atom(_) -> jsonrpc_error.
{handle_response, ID, Reply}.

start_update(URL) ->
case is_running(grisp_updater) of
true -> grisp_updater:start(URL,
grisp_connect_updater_progress,
#{client => self()}, #{});
false -> {error, grisp_updater_unavailable}
end.

get_partition_info() ->
case is_running(grisp_updater) of
true ->
Info = grisp_updater:info(),
#{boot := Boot, valid := Valid, next := Next} = Info,
ActionRequired = maps:get(action_required, Info, false),
case evaluate_partition_state(Boot, Valid, Next) of
new_boot ->
#{state => <<"new">>,
message => <<"New partition booted, validation required">>,
action_required => ActionRequired};
update_pending ->
#{state => <<"old">>,
message => <<"Reboot required to load new partition">>,
action_required => ActionRequired};
no_update_pending ->
#{state => <<"old_no_update">>,
message => <<"No update pending, running old partition">>,
action_required => ActionRequired};
_ ->
#{state => <<"unknown">>,
message => <<"Unknown partition state">>,
action_required => ActionRequired}
end;
false -> {error, grisp_updater_unavailable}
end.

evaluate_partition_state(BootPartition, ValidPartition, NextPartition) ->
case {BootPartition, ValidPartition, NextPartition} of
% Case 1: Booting from removable media, but system has a pending update
{#{type := removable},
#{type := system, id := ValidId},
#{type := system, id := NextId}}
when ValidId =/= NextId -> update_pending;
% Case 2: Booted from system partition, but a different system partition is pending update
{#{type := system, id := BootId},
#{type := system, id := ValidId},
#{type := system, id := NextId}}
when BootId == ValidId, ValidId =/= NextId -> update_pending;
% Case 3: Booted from a new partition, validation required
{#{type := system, id := BootId},
#{type := system, id := ValidId},
_}
when BootId =/= ValidId -> new_boot;
% Case 4: Booted from removable media, no update pending
{#{type := removable},
#{type := system, id := ValidId},
#{type := system, id := NextId}}
when ValidId == NextId -> no_update_pending;
% Case 5: Booted from system partition, no update pending
{#{type := system, id := BootId},
_,
#{type := system, id := NextId}}
when NextId == BootId -> no_update_pending;
% Default case: Unknown partition state
_ -> unknown_state
end.

is_running(AppName) ->
Apps = application:which_applications(),
case [App || {App, _Desc, _VSN} <- Apps, App =:= AppName] of
[] -> false;
[_] -> true
end.

error_atom(-1) -> device_not_linked;
error_atom(-2) -> token_expired;
error_atom(-3) -> device_already_linked;
error_atom(-4) -> invalid_token;
error_atom(_) -> jsonrpc_error.

id() ->
list_to_binary(integer_to_list(erlang:unique_integer())).
Loading

0 comments on commit 7521fec

Please sign in to comment.