defmodule Speedtest do @moduledoc """ Speedtest.net client for Elixir. """ alias Speedtest.Ping alias Speedtest.Decoder alias Speedtest.Result require Logger @ttl 25000 defstruct config: [], servers: [], include: nil, exclude: nil, threads: nil, selected_server: nil, result: nil @doc """ Retrieve a the list of speedtest.net servers, optionally filtered to servers matching those specified in the servers argument ## Examples iex> Speedtest.fetch_servers(%Speedtest{}) """ def fetch_servers(%Speedtest{} = speedtest \\ %Speedtest{}) do Logger.info("Retrieving speedtest.net server list...") urls = [ "https://www.speedtest.net/speedtest-servers-static.php", "http://c.speedtest.net/speedtest-servers-static.php", "https://www.speedtest.net/speedtest-servers.php", "http://c.speedtest.net/speedtest-servers.php" ] first = List.first(urls) {_, response} = fetch_server(first) result = Decoder.server(response) result = case speedtest.include == nil do true -> result false -> Enum.reject(speedtest.include, fn x -> Enum.member?(result, x) == false end) end result = case speedtest.exclude == nil do true -> result false -> Enum.reject(speedtest.exclude, fn x -> Enum.member?(result, x) end) end reply = %{speedtest | servers: result} {:ok, reply} end @doc """ Limit servers to the closest speedtest.net servers based on geographic distance ## Examples iex> Speedtest.choose_closest_servers() """ def choose_closest_servers(servers \\ [], amount \\ 2) do Enum.sort_by(servers, fn s -> s.distance end) |> Enum.take(amount) end @doc """ Perform a speedtest.net "ping" to determine which speedtest.net server has the lowest latency ## Examples iex> Speedtest.choose_best_server([]) """ def choose_best_server(servers) do Logger.info("Selecting best server based on ping...") Enum.map(servers, fn s -> url = Decoder.url(s.host) ping = Speedtest.Ping.ping(url) Map.put(s, :ping, ping) end) |> Enum.sort_by(fn s -> s.ping end) |> List.first(servers) end @doc """ Test download speed against speedtest.net ## Examples iex> Speedtest.download(%Speedtest{}) """ def download(%Speedtest{} = speedtest \\ %Speedtest{}) do Logger.info("Testing download speed...") {_, urls} = generate_download_urls(speedtest) threads = case is_nil(speedtest.threads) do true -> speedtest.config.threads.download false -> speedtest.threads end urls = Enum.chunk_every(urls, threads) responses = Enum.map(urls, fn data -> Enum.map(data, fn url -> timed_fetch(url) end) |> Task.await_many(@ttl) end) |> List.flatten() {:ok, responses} end defp timed_fetch(url) do Task.async(fn -> {time_in_microseconds, return} = :timer.tc(fn -> {_, reply} = HTTPoison.get(url, [], recv_timeout: @ttl) reply end) [{_, length}] = Enum.filter(return.headers, fn h -> {key, _} = h key == "Content-Length" end) time_in_seconds = time_in_microseconds / 1_000_000 %{elapsed_time: time_in_seconds, bytes: String.to_integer(length), url: url} end) end defp timed_post(url, size) do Task.async(fn -> event_time = System.monotonic_time(:millisecond) headers = [{"Content-length", size}] body = "" HTTPoison.post(url, body, headers, recv_timeout: @ttl) time_in_milliseconds = System.monotonic_time(:millisecond) - event_time %{elapsed_time: time_in_milliseconds / 1_000, bytes: size, url: url} end) end @doc """ Test upload speed against speedtest.net ## Examples iex> Speedtest.upload(%Speedtest{}) """ def upload(%Speedtest{} = speedtest \\ %Speedtest{}) do Logger.info("Testing Upload Speed...") {_, data} = generate_upload_data(speedtest) threads = case is_nil(speedtest.threads) do true -> speedtest.config.threads.upload false -> speedtest.threads end data = Enum.chunk_every(data, threads) responses = Enum.map(data, fn data -> Enum.map(data, fn {url, size} -> timed_post(url, size) end) |> Task.await_many(@ttl) end) |> List.flatten() {:ok, responses} end @doc """ Determine distance between sets of [lat,lon] in km ## Examples iex> Speedtest.distance(%Speedtest{}) """ def distance(%Speedtest{} = speedtest \\ %Speedtest{}) do servers = Enum.map(speedtest.servers, fn s -> distance = Geocalc.distance_between([speedtest.config.client.lat, speedtest.config.client.lon], [ s.lat, s.lon ]) |> Float.round() |> trunc() Map.put(s, :distance, distance) end) speedtest = %{speedtest | servers: servers} {:ok, speedtest} end @doc """ Run the full speedtest.net test ## Examples iex> Speedtest.run() """ def run() do {_, init} = init() {_, result} = fetch_config_data() config = Decoder.config(result) Logger.info("Testing from " <> config.client.isp <> " (" <> config.client.ip <> ")...") {_, result} = fetch_servers(init) speedtest = %{result | config: config} {_, result} = distance(speedtest) closest_servers = choose_closest_servers(result.servers) selected_server = choose_best_server(closest_servers) {status, _, response} = selected_server.ping reply = case status do :ok -> ping = to_string(response) elapsed_time = ping <> " ms" Logger.info( "Hosted by " <> selected_server.sponsor <> " (" <> selected_server.name <> ") " <> "[" <> to_string(Float.round(selected_server.distance / 1000)) <> " km]: " <> elapsed_time ) speedtest = %{result | selected_server: selected_server} {_, download_reply} = download(speedtest) {_, upload_reply} = upload(speedtest) replys = {upload_reply, download_reply} {_, reply} = Result.create(speedtest, replys) speed = to_string(reply.result.download) <> " Mbit/s" Logger.info("Download: " <> speed) speed = to_string(reply.result.upload) <> " Mbit/s" Logger.info("Upload: " <> speed) config = reply.config client = reply.result.client config = %{config | client: client} %{reply | config: config} _ -> response end {status, reply} end @doc """ Ping an IP and return a tuple with the time ## Examples iex> Speedtest.ping("127.0.0.1") """ def ping(ip) do Ping.ping(ip) end @doc """ Setup the base speedtest ## Examples iex> Speedtest.init() {:ok, %Speedtest{config: [],exclude: nil,include: nil,result: nil,selected_server: nil,servers: [],threads: nil}} """ def init() do threads = Application.get_env(:speedtest, :threads) include = Application.get_env(:speedtest, :include) exclude = Application.get_env(:speedtest, :exclude) st = %Speedtest{} reply = %{st | threads: threads, include: include, exclude: exclude} {:ok, reply} end defp generate_download_urls(%Speedtest{} = speedtest) do urls = Enum.map(speedtest.config.sizes.download, fn s -> size = to_string(s) speedtest.selected_server.host <> "/speedtest/random" <> size <> "x" <> size <> ".jpg" end) urls = Enum.shuffle(urls) {:ok, urls} end defp generate_upload_data(%Speedtest{} = speedtest) do data = Enum.map(speedtest.config.sizes.upload, fn s -> {speedtest.selected_server.url, to_string(s)} end) {:ok, data} end defp user_agent() do {"User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.117 Safari/537.36"} end defp fetch_server(server) do HTTPoison.get(server, [user_agent()], hackney: [headers: [user_agent()]]) end defp fetch_config_data() do Logger.info("Retrieving speedtest.net configuration...") {status, response} = HTTPoison.get( "https://www.speedtest.net/speedtest-config.php", [user_agent()], hackney: [headers: [user_agent()]] ) {status, response} end end