Write some tests for configurator
parent
92fef18b62
commit
d6925b1541
|
@ -27,4 +27,9 @@ config :farmbot,
|
|||
{Farmbot.Platform.Host.Configurator, []}
|
||||
]
|
||||
|
||||
config :farmbot, FarmbotOS.Configurator,
|
||||
data_layer: FarmbotTest.Configurator.MockDataLayer,
|
||||
network_layer: FarmbotTest.Configurator.MockNetworkLayer
|
||||
|
||||
config :farmbot, FarmbotOS.FirmwareTTYDetector, expected_names: []
|
||||
config :plug, :validate_header_keys_during_test, true
|
||||
|
|
|
@ -26,7 +26,7 @@ defmodule FarmbotOS.Configurator.Router do
|
|||
|
||||
get "/" do
|
||||
case load_last_reset_reason() do
|
||||
{:ok, reason} when is_binary(reason) ->
|
||||
reason when is_binary(reason) ->
|
||||
if String.contains?(reason, "CeleryScript request.") do
|
||||
render_page(conn, "index", version: version(), last_reset_reason: nil)
|
||||
else
|
||||
|
@ -53,13 +53,13 @@ defmodule FarmbotOS.Configurator.Router do
|
|||
conn
|
||||
|> put_resp_content_type("application/octet-stream")
|
||||
|> put_resp_header(
|
||||
"Content-Disposition",
|
||||
"content-disposition",
|
||||
"inline; filename=\"#{version()}-#{md5}-logs.sqlite3\""
|
||||
)
|
||||
|> send_resp(200, data)
|
||||
|
||||
{:error, posix} ->
|
||||
send_resp(conn, 404, "Error downloading file: #{posix}")
|
||||
send_resp(conn, 500, "Error downloading file: #{posix}")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -105,7 +105,7 @@ defmodule FarmbotOS.Configurator.Router do
|
|||
end
|
||||
|
||||
get "/config_wireless" do
|
||||
ifname = get_session("ifname")
|
||||
ifname = get_session(conn, "ifname")
|
||||
|
||||
render_page(conn, "/config_wireless_step_1",
|
||||
ifname: ifname,
|
||||
|
@ -115,7 +115,7 @@ defmodule FarmbotOS.Configurator.Router do
|
|||
end
|
||||
|
||||
post "config_wireless_step_1" do
|
||||
ifname = get_session("ifname")
|
||||
ifname = get_session(conn, "ifname")
|
||||
ssid = conn.params["ssid"] |> remove_empty_string()
|
||||
security = conn.params["security"] |> remove_empty_string()
|
||||
manualssid = conn.params["manualssid"] |> remove_empty_string()
|
||||
|
@ -221,18 +221,18 @@ defmodule FarmbotOS.Configurator.Router do
|
|||
%{"email" => email, "password" => pass, "server" => server} ->
|
||||
if server = test_uri(server) do
|
||||
FarmbotCore.Logger.info(1, "server valid: #{server}")
|
||||
|
||||
conn
|
||||
|> put_session("auth_email", email)
|
||||
|> put_session("auth_password", pass)
|
||||
|> put_session("auth_server", server)
|
||||
|> redir("/finish")
|
||||
else
|
||||
conn
|
||||
|> put_session("__error", "Server is not a valid URI")
|
||||
|> redir("/credentials")
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_session("email", email)
|
||||
|> put_session("password", pass)
|
||||
|> put_session("server", server)
|
||||
|> redir("/finish")
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_session("__error", "Email, Server, or Password are missing or invalid")
|
||||
|
|
|
@ -42,6 +42,7 @@
|
|||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
|
||||
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
|
||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
|
||||
"mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"},
|
||||
"muontrap": {:hex, :muontrap, "0.4.3", "f6c6d2c4e6eeb3b03780da77741945cab1a5ee7fb6d3e9ce9080717b6b4042a7", [:make, :mix], [{:elixir_make, "~> 0.5", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"nerves": {:hex, :nerves, "1.4.4", "7d2d6c0129d541e12ed12117ae059e7db8849faaefc4b4d30f4af55ba6f8d089", [:mix], [{:distillery, "2.0.12", [hex: :distillery, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"nerves_firmware_ssh": {:hex, :nerves_firmware_ssh, "0.4.2", "315b515b872b7a4f18ae1bc37f7943064dfda984ec5ec4a6111b9d69adecee2e", [:mix], [{:nerves_runtime, "~> 0.4", [hex: :nerves_runtime, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
<%= for ssid_result <- ssids do %>
|
||||
<%= if ssid_result.ssid && String.printable?(ssid_result.ssid) do %>
|
||||
<tr id="<%= ssid_result.bssid %>" name="ssidSelector" class="ssid_result"
|
||||
onclick='selectSsid("<%= escape_javascript(ssid_result.ssid) %>", "<%= escape_javascript(ssid_result.bssid) %>", "<%= escape_javascript(to_string(ssid_result.security)) %>")' >
|
||||
onclick='selectSsid("<%= javascript_escape(ssid_result.ssid) %>", "<%= javascript_escape(ssid_result.bssid) %>", "<%= javascript_escape(to_string(ssid_result.security)) %>")' >
|
||||
<td class="ssid_name"> <%= ssid_result.ssid || "hidden network" %> </td>
|
||||
<td class="ssid_quality">
|
||||
<script>
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
</select>
|
||||
|
||||
<div id="pskdiv">
|
||||
<label for=psk> Password </label>
|
||||
<label for=psk> PSK </label>
|
||||
<div class="psk-input-group">
|
||||
<input type="password" name="psk" id="psk">
|
||||
<img class="eye-icon" src="icon_eye.svg"
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
function copyLSR() {
|
||||
var tmp = document.createElement("div");
|
||||
<%= if last_reset_reason do %>
|
||||
tmp.innerHTML = "<%= escape_javascript(last_reset_reason) %>";
|
||||
tmp.innerHTML = "<%= javascript_escape(last_reset_reason) %>";
|
||||
<% end %>
|
||||
var textArea = document.createElement("textarea");
|
||||
textArea.value += "```\n";
|
||||
|
@ -65,7 +65,7 @@
|
|||
<div id="last-shutdown-reason-content">
|
||||
<i> Last shutdown reason: </i>
|
||||
<script>
|
||||
var all = "<%= escape_javascript(last_reset_reason) %>";
|
||||
var all = "<%= javascript_escape(last_reset_reason) %>";
|
||||
var reason = all.split("<hr>")[0];
|
||||
document.write(reason)
|
||||
</script>
|
||||
|
@ -76,7 +76,7 @@
|
|||
</label>
|
||||
<div id="envDetails" class="env-details" hidden>
|
||||
<script>
|
||||
var all = "<%= escape_javascript(last_reset_reason) %>";
|
||||
var all = "<%= javascript_escape(last_reset_reason) %>";
|
||||
var envDetails = all.split("<hr>")[1];
|
||||
document.write(envDetails)
|
||||
</script>
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
<div class="colapsablecontent">
|
||||
<%= log.message %>
|
||||
</div>
|
||||
<%= end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
defmodule DummyTest do
|
||||
use ExUnit.Case
|
||||
|
||||
test "delete me" do
|
||||
assert true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,359 @@
|
|||
defmodule FarmbotOS.Configurator.RouterTest do
|
||||
alias FarmbotOS.Configurator.Router
|
||||
use ExUnit.Case, async: true
|
||||
use Plug.Test
|
||||
|
||||
alias FarmbotTest.Configurator.{MockDataLayer, MockNetworkLayer}
|
||||
import Mox
|
||||
setup :verify_on_exit!
|
||||
|
||||
@opts Router.init([])
|
||||
|
||||
test "index" do
|
||||
MockDataLayer
|
||||
|> expect(:load_last_reset_reason, fn -> "whoops!" end)
|
||||
|
||||
conn = conn(:get, "/")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.resp_body =~ "Configure your Farmbot"
|
||||
assert conn.resp_body =~ "<div class=\"last-shutdown-reason\">"
|
||||
assert conn.resp_body =~ "whoops!"
|
||||
end
|
||||
|
||||
test "redirect to index" do
|
||||
MockDataLayer
|
||||
|> expect(:load_last_reset_reason, fn -> nil end)
|
||||
|
||||
conn = conn(:get, "/setup")
|
||||
conn = Router.call(conn, @opts)
|
||||
redir = redirected_to(conn)
|
||||
assert redir == "/"
|
||||
|
||||
conn = conn(:get, redir)
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.resp_body =~ "Configure your Farmbot"
|
||||
end
|
||||
|
||||
test "celeryscript requests don't get listed as last reset reason" do
|
||||
MockDataLayer
|
||||
|> expect(:load_last_reset_reason, fn -> "CeleryScript request." end)
|
||||
|
||||
conn = conn(:get, "/")
|
||||
conn = Router.call(conn, @opts)
|
||||
refute conn.resp_body =~ "CeleryScript request."
|
||||
end
|
||||
|
||||
test "no reset reason" do
|
||||
MockDataLayer
|
||||
|> expect(:load_last_reset_reason, fn -> nil end)
|
||||
|
||||
conn = conn(:get, "/")
|
||||
conn = Router.call(conn, @opts)
|
||||
refute conn.resp_body =~ "<div class=\"last-shutdown-reason\">"
|
||||
end
|
||||
|
||||
test "captive portal" do
|
||||
conn = conn(:get, "/generate_204")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.status == 204
|
||||
|
||||
conn = conn(:get, "/gen_204")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.status == 204
|
||||
end
|
||||
|
||||
test "secret log view page" do
|
||||
MockDataLayer
|
||||
|> expect(:dump_logs, fn -> [%{message: "hello, world!"}] end)
|
||||
|
||||
conn = conn(:get, "/view_logs")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.resp_body =~ "hello, world!"
|
||||
end
|
||||
|
||||
test "secret log download page" do
|
||||
MockDataLayer
|
||||
|> expect(:dump_log_db, fn -> {:error, :enoent} end)
|
||||
|
||||
conn = conn(:get, "/logs")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.status == 500
|
||||
assert conn.resp_body == "Error downloading file: enoent"
|
||||
|
||||
MockDataLayer
|
||||
|> expect(:dump_log_db, fn -> {:ok, "this is supposed to be a sqlite db"} end)
|
||||
|
||||
conn = conn(:get, "/logs")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body == "this is supposed to be a sqlite db"
|
||||
[disposition] = get_resp_header(conn, "content-disposition")
|
||||
assert disposition =~ "-logs.sqlite3"
|
||||
end
|
||||
|
||||
test "network index" do
|
||||
MockNetworkLayer
|
||||
|> expect(:list_interfaces, fn -> [{"eth0", %{mac_address: "aa:bb:cc:dd:ee"}}] end)
|
||||
|
||||
conn = conn(:get, "/network")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert conn.resp_body =~ "eth0"
|
||||
end
|
||||
|
||||
test "select network sets session data" do
|
||||
conn = conn(:post, "select_interface")
|
||||
conn = Router.call(conn, @opts)
|
||||
assert redirected_to(conn) == "/network"
|
||||
|
||||
conn = conn(:post, "select_interface", %{"interface" => "eth0"})
|
||||
conn = Router.call(conn, @opts)
|
||||
assert redirected_to(conn) == "/config_wired"
|
||||
assert get_session(conn, "ifname") == "eth0"
|
||||
|
||||
conn = conn(:post, "select_interface", %{"interface" => "wlan0"})
|
||||
conn = Router.call(conn, @opts)
|
||||
assert redirected_to(conn) == "/config_wireless"
|
||||
assert get_session(conn, "ifname") == "wlan0"
|
||||
end
|
||||
|
||||
test "config wired" do
|
||||
conn =
|
||||
conn(:get, "/config_wired")
|
||||
|> init_test_session(%{"ifname" => "eth0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "Advanced settings"
|
||||
end
|
||||
|
||||
test "config wireless SSID list" do
|
||||
MockNetworkLayer
|
||||
|> expect(:scan, fn _ ->
|
||||
[
|
||||
%{
|
||||
ssid: "Test Network",
|
||||
bssid: "aa:bb:cc:dd:ee:ff",
|
||||
security: "WPA-PSK",
|
||||
level: 100
|
||||
}
|
||||
]
|
||||
end)
|
||||
|
||||
conn =
|
||||
conn(:get, "/config_wireless")
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "Test Network"
|
||||
end
|
||||
|
||||
test "config wireless" do
|
||||
# No SSID or SECURITY
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert redirected_to(conn) == "/config_wireless"
|
||||
|
||||
# No SECURITY
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{"ssid" => "Test Network"})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert redirected_to(conn) == "/config_wireless"
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{"ssid" => "Test Network", "security" => "NONE"})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
refute conn.resp_body =~ "PSK"
|
||||
assert conn.resp_body =~ "Advanced settings"
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{"ssid" => "Test Network", "security" => "WPA-PSK"})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "PSK"
|
||||
assert conn.resp_body =~ "Advanced settings"
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{"ssid" => "Test Network", "security" => "WPA2-PSK"})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "PSK"
|
||||
assert conn.resp_body =~ "Advanced settings"
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{"ssid" => "Test Network", "security" => "WPA-EAP"})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
refute conn.resp_body =~ "PSK"
|
||||
assert conn.resp_body =~ "IDENTITY"
|
||||
assert conn.resp_body =~ "PASSWORD"
|
||||
assert conn.resp_body =~ "Advanced settings"
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{"manualssid" => "Test Network"})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "PSK"
|
||||
assert conn.resp_body =~ "EAP Identity"
|
||||
assert conn.resp_body =~ "EAP Password"
|
||||
assert conn.resp_body =~ "Advanced settings"
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_wireless_step_1", %{
|
||||
"ssid" => "Test Network",
|
||||
"security" => "WPA-UNSUPPORTED"
|
||||
})
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "unknown or unsupported"
|
||||
end
|
||||
|
||||
test "config_network" do
|
||||
params = %{
|
||||
"dns_name" => "super custom",
|
||||
"ntp_server_1" => "pool0.ntpd.org",
|
||||
"ntp_server_2" => "pool1.ntpd.org",
|
||||
"ssh_key" => "very long ssh key",
|
||||
"ssid" => "Test Network",
|
||||
"security" => "WPA-PSK",
|
||||
"psk" => "ABCDEF",
|
||||
"identity" => "NOT TECHNICALLY POSSIBLE",
|
||||
"password" => "NOT TECHNICALLY POSSIBLE",
|
||||
"domain" => "farmbot.org",
|
||||
"name_servers" => "192.168.1.1, 192.168.1.2",
|
||||
"ipv4_method" => "static",
|
||||
"ipv4_address" => "192.168.1.100",
|
||||
"ipv4_gateway" => "192.168.1.1",
|
||||
"ipv4_subnet_mask" => "255.255.0.0",
|
||||
"regulatory_domain" => "US"
|
||||
}
|
||||
|
||||
conn =
|
||||
conn(:post, "/config_network", params)
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert get_session(conn, "net_config_dns_name") == "super custom"
|
||||
assert get_session(conn, "net_config_ntp1") == "pool0.ntpd.org"
|
||||
assert get_session(conn, "net_config_ntp2") == "pool1.ntpd.org"
|
||||
assert get_session(conn, "net_config_ssh_key") == "very long ssh key"
|
||||
assert get_session(conn, "net_config_ssid") == "Test Network"
|
||||
assert get_session(conn, "net_config_security") == "WPA-PSK"
|
||||
assert get_session(conn, "net_config_psk") == "ABCDEF"
|
||||
assert get_session(conn, "net_config_identity") == "NOT TECHNICALLY POSSIBLE"
|
||||
assert get_session(conn, "net_config_password") == "NOT TECHNICALLY POSSIBLE"
|
||||
assert get_session(conn, "net_config_domain") == "farmbot.org"
|
||||
assert get_session(conn, "net_config_name_servers") == "192.168.1.1, 192.168.1.2"
|
||||
assert get_session(conn, "net_config_ipv4_method") == "static"
|
||||
assert get_session(conn, "net_config_ipv4_address") == "192.168.1.100"
|
||||
assert get_session(conn, "net_config_ipv4_gateway") == "192.168.1.1"
|
||||
assert get_session(conn, "net_config_ipv4_subnet_mask") == "255.255.0.0"
|
||||
assert get_session(conn, "net_config_reg_domain") == "US"
|
||||
assert redirected_to(conn) == "/credentials"
|
||||
end
|
||||
|
||||
test "credentials index" do
|
||||
MockDataLayer
|
||||
|> expect(:load_email, fn -> "test@test.org" end)
|
||||
|> expect(:load_password, fn -> "password123" end)
|
||||
|> expect(:load_server, fn -> "https://my.farm.bot" end)
|
||||
|
||||
conn = conn(:get, "/credentials") |> Router.call(@opts)
|
||||
assert conn.resp_body =~ "test@test.org"
|
||||
assert conn.resp_body =~ "password123"
|
||||
assert conn.resp_body =~ "https://my.farm.bot"
|
||||
end
|
||||
|
||||
test "configure credentials" do
|
||||
params = %{
|
||||
"email" => "test@test.org",
|
||||
"password" => "password123",
|
||||
"server" => "https://my.farm.bot"
|
||||
}
|
||||
|
||||
conn =
|
||||
conn(:post, "/configure_credentials", params)
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert redirected_to(conn) == "/finish"
|
||||
assert get_session(conn, "auth_email") == "test@test.org"
|
||||
assert get_session(conn, "auth_password") == "password123"
|
||||
assert get_session(conn, "auth_server") == "https://my.farm.bot"
|
||||
|
||||
conn =
|
||||
conn(:post, "/configure_credentials", %{params | "server" => "whoops/i/made/a/type"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert redirected_to(conn) == "/credentials"
|
||||
|
||||
conn =
|
||||
conn(:post, "/configure_credentials", %{})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert redirected_to(conn) == "/credentials"
|
||||
end
|
||||
|
||||
test "finish" do
|
||||
conn =
|
||||
conn(:get, "/finish")
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body =~ "Configuration Complete"
|
||||
end
|
||||
|
||||
test "404" do
|
||||
conn =
|
||||
conn(:get, "/whoops")
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.resp_body == "Page not found"
|
||||
end
|
||||
|
||||
test "500" do
|
||||
MockNetworkLayer
|
||||
|> expect(:scan, fn _ ->
|
||||
[
|
||||
%{
|
||||
incorrect: :data
|
||||
}
|
||||
]
|
||||
end)
|
||||
|
||||
conn =
|
||||
conn(:get, "/config_wireless")
|
||||
|> init_test_session(%{"ifname" => "wlan0"})
|
||||
|> Router.call(@opts)
|
||||
|
||||
assert conn.status == 500
|
||||
end
|
||||
|
||||
# Stolen from https://github.com/phoenixframework/phoenix/blob/3f157c30ceae8d1eb524fdd05b5e3de10e434c42/lib/phoenix/test/conn_test.ex#L438
|
||||
defp redirected_to(conn, status \\ 302)
|
||||
|
||||
defp redirected_to(%Plug.Conn{state: :unset}, _status) do
|
||||
raise "expected connection to have redirected but no response was set/sent"
|
||||
end
|
||||
|
||||
defp redirected_to(conn, status) when is_atom(status) do
|
||||
redirected_to(conn, Plug.Conn.Status.code(status))
|
||||
end
|
||||
|
||||
defp redirected_to(%Plug.Conn{status: status} = conn, status) do
|
||||
location = Plug.Conn.get_resp_header(conn, "location") |> List.first()
|
||||
location || raise "no location header was set on redirected_to"
|
||||
end
|
||||
|
||||
defp redirected_to(conn, status) do
|
||||
raise "expected redirection with status #{status}, got: #{conn.status}"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,2 @@
|
|||
Mox.defmock(FarmbotTest.Configurator.MockDataLayer, for: FarmbotOS.Configurator.DataLayer)
|
||||
Mox.defmock(FarmbotTest.Configurator.MockNetworkLayer, for: FarmbotOS.Configurator.NetworkLayer)
|
Loading…
Reference in New Issue