#!/bin/sh # Localtonet Speed Test for the terminal: download, upload, ping, jitter and bufferbloat. # # curl -fsSL https://speed.localtonet.com/sh | sh # curl -fsSL https://speed.localtonet.com/sh | sh -s -- --json # # Works on macOS and Linux with nothing to install: it needs only a POSIX shell and curl. Its temporary # files live in one directory that is removed when the test ends, and results are not sent anywhere. # On Windows, run the PowerShell version instead: irm https://speed.localtonet.com/ps | iex # # Everything runs from main at the bottom, so a partly downloaded copy never starts. ENDPOINT='https://speed.cloudflare.com' # speed.localtonet.com fills in the configured endpoint; a raw copy of this file uses the default. case $ENDPOINT in *'{{'*) ENDPOINT='https://speed.cloudflare.com' ;; esac SITE='https://speed.localtonet.com' VERSION='1.0.0' UA="localtonet-speedtest/$VERSION (sh)" PHASE_SECONDS=8 LATENCY_SAMPLES=20 STREAM_REQUESTS=80 # One line per finished request; the stream logs and the probe parse these five fields. FORMAT='%{http_code} %{size_download} %{size_upload} %{time_pretransfer} %{time_total}\n' # Server names by colo code (";IST=Istanbul;FRA=Frankfurt;"), filled in from SpeedTestColos.cs. Without them # the server shows under Cloudflare's own city for it. COLOS=';AAE=Annaba;ABJ=Abidjan;ABQ=Albuquerque;ACC=Accra;ADB=Izmir;ADD=Addis Ababa;ADL=Adelaide;AGR=Agra;AIP=Jalandhar;AKL=Auckland;AKX=Aktobe;ALA=Almaty;ALG=Algiers;AMD=Ahmedabad;AMM=Amman;AMS=Amsterdam;ANC=Anchorage;ARI=Arica;ARN=Stockholm;ARU=Araçatuba;ASK=Yamoussoukro;ASU=Asunción;ATH=Athens;ATL=Atlanta;AUS=Austin;BAH=Manama;BAQ=Barranquilla;BBI=Bhubaneswar;BCN=Barcelona;BDQ=Vadodara;BEG=Belgrade;BEL=Belém;BER=Berlin;BEY=Beirut;BGI=Bridgetown;BGR=Bangor;BGW=Baghdad;BKK=Bangkok;BLR=Bengaluru;BNA=Nashville;BNE=Brisbane;BOD=Bordeaux;BOG=Bogotá;BOM=Mumbai;BOS=Boston;BRU=Brussels;BSB=Brasília;BSR=Basra;BTS=Bratislava;BUD=Budapest;BWN=Bandar Seri Begawan;CAI=Cairo;CAW=Campos dos Goytacazes;CBR=Canberra;CCU=Kolkata;CDG=Paris;CEB=Cebu;CFC=Caçador;CGB=Cuiabá;CGK=Jakarta;CGP=Chittagong;CGY=Cagayan de Oro;CHC=Christchurch;CJB=Coimbatore;CLE=Cleveland;CLO=Cali;CLT=Charlotte;CMB=Colombo;CMH=Columbus;CNF=Belo Horizonte;CNN=Kannur;CNX=Chiang Mai;COK=Kochi;COR=Córdoba;CPH=Copenhagen;CPT=Cape Town;CRK=Clark;CVG=Cincinnati;CWB=Curitiba;CZL=Constantine;DAC=Dhaka;DAD=Da Nang;DAR=Dar es Salaam;DEL=New Delhi;DEN=Denver;DFW=Dallas;DKR=Dakar;DLA=Douala;DME=Moscow;DMM=Dammam;DOH=Doha;DPS=Denpasar;DTW=Detroit;DUB=Dublin;DUR=Durban;DUS=Düsseldorf;DXB=Dubai;DYU=Dushanbe;EBB=Kampala;EBL=Erbil;ESB=Ankara;EVN=Yerevan;EWR=Newark;EZE=Buenos Aires;FCO=Rome;FLN=Florianópolis;FOR=Fortaleza;FRA=Frankfurt;FRU=Bishkek;FSD=Sioux Falls;FUK=Fukuoka;GBE=Gaborone;GDL=Guadalajara;GEO=Georgetown;GIG=Rio de Janeiro;GND=St. George'\''s;GOT=Gothenburg;GRU=São Paulo;GUA=Guatemala City;GUM=Guam;GVA=Geneva;GYD=Baku;GYE=Guayaquil;GYN=Goiânia;HAM=Hamburg;HAN=Hanoi;HBA=Hobart;HEL=Helsinki;HFA=Haifa;HKG=Hong Kong;HNL=Honolulu;HRE=Harare;HYD=Hyderabad;IAD=Ashburn;IAH=Houston;ICN=Seoul;IND=Indianapolis;ISB=Islamabad;IST=Istanbul;ISU=Sulaymaniyah;IXC=Chandigarh;JAI=Jaipur;JAX=Jacksonville;JDO=Juazeiro do Norte;JED=Jeddah;JFK=New York;JHB=Johor Bahru;JIB=Djibouti;JNB=Johannesburg;JOG=Yogyakarta;JOI=Joinville;JRG=Jharsuguda;KBP=Kyiv;KCH=Kuching;KEF=Reykjavík;KGL=Kigali;KHH=Kaohsiung;KHI=Karachi;KIN=Kingston;KIX=Osaka;KNU=Kanpur;KTM=Kathmandu;KUL=Kuala Lumpur;KWI=Kuwait City;LAD=Luanda;LAS=Las Vegas;LAX=Los Angeles;LCA=Larnaca;LED=Saint Petersburg;LHE=Lahore;LHR=London;LIM=Lima;LIS=Lisbon;LJU=Ljubljana;LLK=Lankaran;LLW=Lilongwe;LOS=Lagos;LPB=La Paz;LUH=Ludhiana;LUN=Lusaka;LUX=Luxembourg;LYS=Lyon;MAA=Chennai;MAD=Madrid;MAN=Manchester;MAO=Manaus;MBA=Mombasa;MCI=Kansas City;MCT=Muscat;MDE=Medellín;MEL=Melbourne;MEM=Memphis;MEX=Mexico City;MFM=Macau;MIA=Miami;MLA=Valletta;MLE=Malé;MLG=Malang;MNL=Manila;MPM=Maputo;MRS=Marseille;MRU=Port Louis;MSP=Minneapolis;MSQ=Minsk;MUC=Munich;MXP=Milan;NAG=Nagpur;NBO=Nairobi;NJF=Najaf;NOU=Nouméa;NQN=Neuquén;NQZ=Astana;NRT=Tokyo;NVT=Navegantes;OKA=Naha;OKC=Oklahoma City;OMA=Omaha;ORD=Chicago;ORF=Norfolk;ORN=Oran;OSL=Oslo;OTP=Bucharest;OUA=Ouagadougou;PAT=Patna;PBH=Paro;PBM=Paramaribo;PDX=Portland;PER=Perth;PHL=Philadelphia;PHX=Phoenix;PIT=Pittsburgh;PMO=Palermo;PMW=Palmas;PNH=Phnom Penh;PNQ=Pune;POA=Porto Alegre;POS=Port of Spain;PPT=Papeete;PRG=Prague;PTY=Panama City;QRO=Querétaro;QWJ=Americana;RAO=Ribeirão Preto;RDU=Raleigh;REC=Recife;RIC=Richmond;RIX=Riga;RUH=Riyadh;RUN=Saint-Denis;SAN=San Diego;SAP=San Pedro Sula;SAT=San Antonio;SCL=Santiago;SDQ=Santo Domingo;SEA=Seattle;SFO=San Francisco;SGN=Ho Chi Minh City;SIN=Singapore;SJC=San Jose;SJK=São José dos Campos;SJO=San José;SJP=São José do Rio Preto;SJU=San Juan;SKG=Thessaloniki;SKP=Skopje;SLC=Salt Lake City;SMF=Sacramento;SOD=Sorocaba;SOF=Sofia;SSA=Salvador;STI=Santiago de los Caballeros;STL=St. Louis;STR=Stuttgart;SUV=Suva;SYD=Sydney;TBS=Tbilisi;TGU=Tegucigalpa;TIA=Tirana;TLH=Tallahassee;TLL=Tallinn;TLV=Tel Aviv;TNR=Antananarivo;TPA=Tampa;TPE=Taipei;TUN=Tunis;TXL=Berlin;UDI=Uberlândia;UDR=Udaipur;UIO=Quito;ULN=Ulaanbaatar;URT=Surat Thani;VCP=Campinas;VIE=Vienna;VIX=Vitória;VNO=Vilnius;VTE=Vientiane;WAW=Warsaw;WDH=Windhoek;WLG=Wellington;WRO=Wrocław;XAP=Chapecó;XNH=Nasiriyah;YHZ=Halifax;YUL=Montréal;YVR=Vancouver;YWG=Winnipeg;YXE=Saskatoon;YYC=Calgary;YYZ=Toronto;ZAG=Zagreb;ZDM=Ramallah;ZRH=Zurich;' case $COLOS in *'{{'*) COLOS=';' ;; esac usage() { cat <&2; usage >&2; exit 2 ;; esac done } setup_terminal() { TTY=0 if [ "$JSON" = 0 ] && [ -t 1 ] && [ "${TERM:-dumb}" != dumb ]; then TTY=1; fi case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *[Uu][Tt][Ff]-8* | *[Uu][Tt][Ff]8*) UTF=1 ;; *) UTF=0 ;; esac BOLD='' DIM='' RESET='' GREEN='' YELLOW='' RED='' ACCENT='' if [ "$TTY" = 1 ] && [ -z "${NO_COLOR:-}" ]; then e=$(printf '\033') BOLD="${e}[1m" DIM="${e}[2m" RESET="${e}[0m" GREEN="${e}[32m" YELLOW="${e}[33m" RED="${e}[31m" ACCENT="${e}[34m" case ${TERM:-} in *256color* | *truecolor* | xterm-kitty | alacritty | wezterm) ACCENT="${e}[38;5;63m" ;; esac fi if [ "$UTF" = 1 ]; then FULL='█' EMPTY='░' SEP='·' DOWN='↓' UP='↑' MARKS='✔ ✔ ~ ✘' SPINNER='⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏' else FULL='#' EMPTY='.' SEP='-' DOWN='down' UP='up' MARKS='+ + ~ x' SPINNER='| / - \' fi BAR=24 if [ "$TTY" = 1 ]; then cols=$(stty size 2>/dev/null &2 exit 1 } check_tools() { command -v curl >/dev/null 2>&1 || die "curl is required. Install it with your package manager (for example: sudo apt install curl) and run this again." command -v awk >/dev/null 2>&1 || die "awk is required." version=$(curl --version 2>/dev/null | LC_ALL=C awk 'NR == 1 { split($2, v, "."); printf "%d", v[1] * 1000 + v[2] }') # Since curl 7.63, %{stderr} writes each request's result the moment it finishes, so speeds update live. WO_ERR='' [ "${version:-0}" -ge 7063 ] && WO_ERR='%{stderr}' # Since curl 7.84 the server's own processing time (Server-Timing) can be taken out of each ping, as the browser test does. TIMING='' [ "${version:-0}" -ge 7084 ] && TIMING=' %header{server-timing}' if sleep 0.2 2>/dev/null; then TICK=0.2 PER_SECOND=5 PING_GAP=0.4; else TICK=1 PER_SECOND=1 PING_GAP=1; fi } stop_jobs() { if [ -n "$JOBS" ]; then # Reaping inside the redirect keeps bash from printing "Terminated" for each job. { kill $JOBS; wait $JOBS; } 2>/dev/null JOBS='' fi } cleanup() { [ -n "${TMP:-}" ] && : 2>/dev/null >"$TMP/stop" stop_jobs [ -n "${TMP:-}" ] && rm -rf "$TMP" [ "${TTY:-0}" = 1 ] && printf '\033[?25h' } interrupted() { stop_jobs [ "$TTY" = 1 ] && printf '\r\033[K' [ "$JSON" = 1 ] || printf '\n %s\n\n' "Test stopped." >&2 exit 130 } tick() { sleep "$TICK" TICKS=$((TICKS + 1)) } frame() { set -- $SPINNER shift $((TICKS % $#)) printf '%s' "$1" } live() { [ "$TTY" = 1 ] && printf '\r\033[K%s' "$1" } done_line() { if [ "$TTY" = 1 ]; then printf '\r\033[K%s\n' "$1" elif [ "$JSON" = 0 ]; then printf '%s\n' "$1" fi } label() { printf ' %s%-13s%s' "$BOLD" "$1" "$RESET" } speed_line() { # label bps LC_ALL=C awk -v label="$1" -v b="$2" -v w="$BAR" -v f="$FULL" -v e="$EMPTY" \ -v bold="$BOLD" -v dim="$DIM" -v acc="$ACCENT" -v r="$RESET" 'BEGIN { m = b / 1e6 x = (m > 0) ? log(1 + m) / log(1001) : 0 if (x > 1) x = 1 n = int(x * w + 0.5) fill = ""; empty = "" for (i = 0; i < w; i++) if (i < n) fill = fill f; else empty = empty e if (b == "" || b <= 0) v = "--" else if (m < 10) v = sprintf("%.2f", m) else if (m < 100) v = sprintf("%.1f", m) else v = sprintf("%.0f", m) printf " %s%-13s%s%s%s%s%s%s%s %s%7s%s Mbps", bold, label, r, acc, fill, r, dim, empty, r, bold, v, r }' } ping_line() { # ping jitter low LC_ALL=C awk -v p="$1" -v j="$2" -v l="$3" -v bold="$BOLD" -v dim="$DIM" -v r="$RESET" ' function ms(v) { if (v == "" || v == "-") return "--"; return v < 10 ? sprintf("%.1f", v) : sprintf("%.0f", v) } BEGIN { printf " %s%-13s%s%s%s%s ms %sjitter%s %s ms %slow%s %s ms", bold, "Ping", r, bold, ms(p), r, dim, r, ms(j), dim, r, ms(l) }' } # Prints "median low jitter count" in milliseconds for a log of "code pretransfer starttransfer [server-timing]" lines. latency_stats() { # file skip LC_ALL=C awk -v skip="$2" ' $1 == 200 && NF >= 3 { if (skip > 0) { skip--; next } v = ($3 - $2) * 1000 server = 0 for (i = 4; i <= NF; i++) if (match($i, /dur=[0-9.]+/)) server += substr($i, RSTART + 4, RLENGTH - 4) if (server > 0 && server < v) v -= server if (v > 0) s[++n] = v } END { if (!n) { print "- - - 0"; exit } for (i = 1; i <= n; i++) a[i] = s[i] for (i = 2; i <= n; i++) { x = a[i]; for (j = i - 1; j >= 1 && a[j] > x; j--) a[j + 1] = a[j]; a[j + 1] = x } median = (n % 2) ? a[(n + 1) / 2] : (a[n / 2] + a[n / 2 + 1]) / 2 jitter = 0 for (i = 2; i <= n; i++) jitter += (s[i] > s[i - 1]) ? s[i] - s[i - 1] : s[i - 1] - s[i] if (n > 1) jitter /= n - 1 printf "%.3f %.3f %.3f %d\n", median, a[1], jitter, n }' "$1" } # Prints "bps bytes". Streams run side by side, so their rates add up. The final figure skips each # stream's first request (TCP slow start); the live figure uses each stream's last three requests. throughput_stats() { # direction final LC_ALL=C awk -v up="$([ "$1" = up ] && echo 1 || echo 0)" -v final="$2" ' $1 == 200 && NF >= 5 { b = up ? $3 : $2 t = $5 - $4 if (b <= 0 || t <= 0) next f = FILENAME if (!(f in count)) order[++files] = f count[f]++ bytes[f, count[f]] = b secs[f, count[f]] = t total += b } END { rate = 0 for (i = 1; i <= files; i++) { f = order[i]; k = count[f] if (final) first = (k > 1) ? 2 : 1 else first = (k > 3) ? k - 2 : 1 bb = 0; tt = 0 for (j = first; j <= k; j++) { bb += bytes[f, j]; tt += secs[f, j] } if (tt > 0) rate += bb * 8 / tt } printf "%.0f %.0f\n", rate, total }' "$TMP/$1".[0-9]* } colo_name() { case $1 in [A-Z][A-Z][A-Z]) ;; *) return 1 ;; esac case $COLOS in *";$1="*) rest=${COLOS#*";$1="}; printf '%s' "${rest%%;*}" ;; *) return 1 ;; esac } json_text() { # json field: first top-level "field":"value" printf '%s' "$1" | sed -n "s/^{[^{]*\"$2\":\"\([^\"]*\)\".*/\1/p" } header() { [ "$JSON" = 1 ] && return printf '\n %s%sLocaltonet%s%s Speed Test%s %s%s%s\n\n' "$BOLD" "$ACCENT" "$RESET" "$BOLD" "$RESET" "$DIM" "${SITE#https://}" "$RESET" } fetch_meta() { live "$(label Server)${DIM}connecting...${RESET}" meta=$(curl $CURL_OPTS -A "$UA" --max-time 5 -H "Referer: $SITE/" "$ENDPOINT/meta" 2>/dev/null) COLO=$(printf '%s' "$meta" | sed -n 's/.*"colo":{[^}]*"iata":"\([A-Z]*\)".*/\1/p') colo_city=$(printf '%s' "$meta" | sed -n 's/.*"colo":{[^}]*"city":"\([^"]*\)".*/\1/p') ASN=$(printf '%s' "$meta" | sed -n 's/.*"asn":\([0-9]*\).*/\1/p') ORG=$(json_text "$meta" asOrganization) city=$(json_text "$meta" city) country=$(json_text "$meta" country) SERVER_NAME=$(colo_name "$COLO") || SERVER_NAME=$colo_city if [ -n "$COLO" ]; then server="${SERVER_NAME:-$COLO} ($COLO)" else server=${ENDPOINT#*://} fi done_line "$(label Server)$server" network=$ORG [ -n "$ASN" ] && network="${network:+$network $SEP }AS$ASN" place=$city [ -n "$country" ] && place="${place:+$place, }$country" [ -n "$place" ] && network="${network:+$network $SEP }$place" [ -n "$network" ] && done_line "$(label Network)$network" done_line '' } measure_latency() { set -- i=0 while [ "$i" -le "$LATENCY_SAMPLES" ]; do SEQ=$((SEQ + 1)) set -- "$@" -o /dev/null "$ENDPOINT/__down?bytes=0&measId=$ID$SEQ" i=$((i + 1)) done # One connection for every sample; the first request only opens it. curl $CURL_OPTS -A "$UA" --max-time 20 -w "$WO_ERR%{http_code} %{time_pretransfer} %{time_starttransfer}$TIMING\n" "$@" >"$TMP/latency" 2>&1 & pid=$! JOBS="$JOBS $pid" while kill -0 "$pid" 2>/dev/null; do set -- $(latency_stats "$TMP/latency" 1) live "$(ping_line "$1" "$3" "$2") $(frame)" tick done wait "$pid" 2>/dev/null JOBS='' set -- $(latency_stats "$TMP/latency" 1) [ "$4" -gt 0 ] || die "Could not reach the speed test server (${ENDPOINT#*://}). Check your connection and try again." PING=$1 PING_LOW=$2 JITTER=$3 done_line "$(ping_line "$PING" "$JITTER" "$PING_LOW")" } probe() { # direction: prints "bps bytes" from one short request SEQ=$((SEQ + 1)) if [ "$1" = down ]; then result=$(curl $CURL_OPTS -A "$UA" --max-time 4 -o /dev/null -w "$FORMAT" "$ENDPOINT/__down?bytes=8000000&measId=$ID$SEQ" 2>/dev/null) else result=$(curl $CURL_OPTS -A "$UA" --max-time 4 -X POST -H 'Expect:' -T "$TMP/probe" -o /dev/null -w "$FORMAT" "$ENDPOINT/__up?measId=$ID$SEQ" 2>/dev/null) fi printf '%s\n' "$result" | LC_ALL=C awk -v up="$([ "$1" = up ] && echo 1 || echo 0)" ' { b = up ? $3 : $2; t = $5 - $4; printf "%.0f %.0f\n", (t > 0 && b > 0) ? b * 8 / t : 0, b } END { if (NR == 0) print "0 0" }' } start_stream() { # direction size log s_dir=$1 s_size=$2 s_log=$3 if [ -n "$WO_ERR" ]; then # One curl, one kept-alive connection, a queue of requests; each finished request adds a line to the log. set -- n=0 while [ "$n" -lt "$STREAM_REQUESTS" ]; do SEQ=$((SEQ + 1)) n=$((n + 1)) if [ "$s_dir" = down ]; then set -- "$@" -o /dev/null "$ENDPOINT/__down?bytes=$s_size&measId=$ID$SEQ" else set -- "$@" -T "$TMP/blob" -o /dev/null "$ENDPOINT/__up?measId=$ID$SEQ" fi done if [ "$s_dir" = down ]; then curl $CURL_OPTS -A "$UA" -w "$WO_ERR$FORMAT" "$@" >/dev/null 2>"$s_log" & else curl $CURL_OPTS -A "$UA" -X POST -H 'Expect:' -w "$WO_ERR$FORMAT" "$@" >/dev/null 2>"$s_log" & fi else # Older curl buffers its write-out until it exits, so each request runs on its own. SEQ=$((SEQ + 1)) s_id="$ID$SEQ" ( c='' # wait returns early on a trapped signal, so stopping the loop also stops its request. trap 'kill $c; exit' TERM n=0 while [ "$n" -lt "$STREAM_REQUESTS" ] && [ ! -f "$TMP/stop" ]; do n=$((n + 1)) if [ "$s_dir" = down ]; then curl $CURL_OPTS -A "$UA" --max-time 30 -o /dev/null -w "$FORMAT" "$ENDPOINT/__down?bytes=$s_size&measId=${s_id}x$n" >>"$s_log" & else curl $CURL_OPTS -A "$UA" --max-time 30 -X POST -H 'Expect:' -T "$TMP/blob" -o /dev/null -w "$FORMAT" "$ENDPOINT/__up?measId=${s_id}x$n" >>"$s_log" & fi c=$! wait "$c" done ) 2>/dev/null & fi JOBS="$JOBS $!" } start_pinger() { # direction p_dir=$1 ( c='' trap 'kill $c; exit' TERM sleep 1 n=0 while [ ! -f "$TMP/stop" ]; do n=$((n + 1)) curl $CURL_OPTS -A "$UA" --max-time 3 -o /dev/null -w "%{http_code} %{time_pretransfer} %{time_starttransfer}$TIMING\n" \ "$ENDPOINT/__down?bytes=0&measId=$ID${p_dir}p$n" >>"$TMP/ping.$p_dir" & c=$! wait "$c" sleep "$PING_GAP" done ) 2>/dev/null & JOBS="$JOBS $!" } transfer_phase() { # direction label dir=$1 name=$2 rm -f "$TMP/stop" probe "$dir" >"$TMP/probe.$dir" & pid=$! JOBS="$JOBS $pid" while kill -0 "$pid" 2>/dev/null; do live "$(speed_line "$name" 0) $(frame)" tick done wait "$pid" 2>/dev/null JOBS='' read -r estimate used <"$TMP/probe.$dir" estimate=${estimate:-0} used=${used:-0} BYTES=$((BYTES + used)) [ "$estimate" -gt 0 ] || die "The $name test could not reach the server. Check your connection and try again." # Faster lines get more parallel streams; each request lasts about 0.75 s at the estimated rate. set -- $(LC_ALL=C awk -v r="$estimate" -v up="$([ "$dir" = up ] && echo 1 || echo 0)" 'BEGIN { s = (r >= 200e6) ? 6 : (r >= 50e6) ? 4 : (r >= 10e6) ? 3 : (r >= 2e6) ? 2 : 1 size = r / 8 * 0.75 / s max = up ? 16777216 : 50003968 if (size < 262144) size = 262144 if (size > max) size = max printf "%d %d\n", s, int(size / 65536) * 65536 }') streams=$1 size=$2 if [ "$dir" = up ]; then dd if=/dev/urandom of="$TMP/blob" bs=65536 count=$((size / 65536)) 2>/dev/null || die "Could not prepare the upload data." fi : >"$TMP/ping.$dir" i=1 while [ "$i" -le "$streams" ]; do : >"$TMP/$dir.$i" start_stream "$dir" "$size" "$TMP/$dir.$i" i=$((i + 1)) done start_pinger "$dir" t=0 while [ "$t" -lt $((PHASE_SECONDS * PER_SECOND)) ]; do tick t=$((t + 1)) if [ "$TTY" = 1 ]; then set -- $(throughput_stats "$dir" 0) rate=$1 [ "$rate" -gt 0 ] || rate=$estimate live "$(speed_line "$name" "$rate") $(frame)" fi done : 2>/dev/null >"$TMP/stop" stop_jobs set -- $(throughput_stats "$dir" 1) rate=$1 [ "$rate" -gt 0 ] || rate=$estimate BYTES=$((BYTES + $2)) set -- $(latency_stats "$TMP/ping.$dir" 0) loaded=$1 [ "$4" -gt 0 ] || loaded='-' if [ "$dir" = down ]; then RATE_DOWN=$rate LOADED_DOWN=$loaded; else RATE_UP=$rate LOADED_UP=$loaded; fi done_line "$(speed_line "$name" "$rate")" } summary() { set -- $MARKS LC_ALL=C awk -v down="$RATE_DOWN" -v up="$RATE_UP" -v ping="$PING" -v jitter="$JITTER" -v low="$PING_LOW" \ -v ld="$LOADED_DOWN" -v lu="$LOADED_UP" -v colo="$COLO" -v server="$SERVER_NAME" -v asn="$ASN" -v org="$ORG" \ -v bytes="$BYTES" -v ts="$(date +%s)" -v iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -v site="$SITE" -v json="$JSON" \ -v bold="$BOLD" -v dim="$DIM" -v r="$RESET" -v green="$GREEN" -v yellow="$YELLOW" -v red="$RED" \ -v m1="$1" -v m2="$2" -v m3="$3" -v m4="$4" -v sdown="$DOWN" -v sup="$UP" ' function has(v) { return v != "" && v != "-" } function ms(v) { if (!has(v)) return "--"; return (v < 10) ? sprintf("%.1f", v) : sprintf("%.0f", v) } function level(great, ok, limited) { return great ? "great" : ok ? "ok" : limited ? "limited" : "poor" } function size(b) { return (b >= 1e9) ? sprintf("%.2f GB", b / 1e9) : (b >= 1e6) ? sprintf("%.0f MB", b / 1e6) : sprintf("%.0f KB", b / 1e3) } function use(title, lvl, text, m, c) { if (lvl == "great") { m = m1; c = green } else if (lvl == "ok") { m = m2; c = "" } else if (lvl == "limited") { m = m3; c = yellow } else { m = m4; c = red } printf " %s%s%s %-18s %s%s%s\n", c, m, r, title, dim, text, r } BEGIN { dm = down / 1e6; um = up / 1e6 loaded = -1 if (has(ld)) loaded = ld + 0 if (has(lu) && lu + 0 > loaded) loaded = lu + 0 added = -1; grade = "" if (loaded >= 0) { added = loaded - ping if (added < 0) added = 0 grade = (added < 5) ? "A+" : (added < 30) ? "A" : (added < 60) ? "B" : (added < 200) ? "C" : (added < 400) ? "D" : "F" } calm = (added < 60) url = sprintf("%s/#r=1&d=%.2f&u=%.2f&p=%.1f&j=%.1f&t=%d", site, dm, um, ping, jitter, ts) if (has(ld)) url = url sprintf("&ld=%.0f", ld) if (has(lu)) url = url sprintf("&lu=%.0f", lu) if (colo ~ /^[A-Z][A-Z][A-Z]$/) url = url "&s=" colo if (json) { printf "{\"download_mbps\":%.2f,\"upload_mbps\":%.2f,\"ping_ms\":%.1f,\"jitter_ms\":%.1f,\"ping_low_ms\":%.1f,", dm, um, ping, jitter, low printf "\"loaded_latency_ms\":{\"download\":%s,\"upload\":%s},", has(ld) ? sprintf("%.0f", ld) : "null", has(lu) ? sprintf("%.0f", lu) : "null" printf "\"bufferbloat\":%s,", (grade == "") ? "null" : "\"" grade "\"" printf "\"server\":{\"colo\":%s,\"city\":%s},", (colo == "") ? "null" : "\"" colo "\"", (server == "") ? "null" : "\"" server "\"" printf "\"network\":{\"asn\":%s,\"organization\":%s},", (asn == "") ? "null" : asn, (org == "") ? "null" : "\"" org "\"" printf "\"data_used_bytes\":%.0f,\"timestamp\":\"%s\",\"report_url\":\"%s\"}\n", bytes, iso, url exit } if (grade != "") { printf " %s%-13s%s%s %s ms %s %s ms\n", bold, "Under load", r, sdown, ms(ld), sup, ms(lu) gc = (grade ~ /^A/) ? green : (grade == "B" || grade == "C") ? yellow : red note = (added < 30) ? "Games and calls stay smooth while the line is busy." : (added < 200) ? "Heavy uploads or downloads can cause noticeable lag." : "Enabling SQM or adaptive QoS on your router usually fixes this." printf " %s%-13s%s%s%s%-2s%s %s+%s ms. %s%s\n", bold, "Bufferbloat", r, bold, gc, grade, r, dim, ms(added), note, r } title = (dm >= 200 && ping <= 30) ? "Fast and responsive" : (dm >= 100) ? "Fast connection" : (dm >= 25) ? "Solid everyday connection" : (dm >= 5) ? "Basic connection" : "Slow connection" if (grade == "D" || grade == "F") title = title ", but laggy under load" printf "\n %s%s%s\n", bold, title, r stream = level(dm >= 25, dm >= 15, dm >= 5) gaming = level(ping <= 30 && jitter <= 10 && calm, ping <= 60 && jitter <= 20, ping <= 100 && jitter <= 40) calls = level(dm >= 10 && um >= 5 && ping <= 80 && jitter <= 30, dm >= 4 && um >= 2 && ping <= 150, um >= 1) hosting = level(um >= 50 && ping <= 60, um >= 10, um >= 3) t["stream", "great"] = (dm >= 75) ? "Several 4K streams at once, with room to spare." : "Smooth 4K HDR on one screen." t["stream", "ok"] = "4K plays, with little headroom for other devices." t["stream", "limited"] = "HD works well; 4K may buffer." t["stream", "poor"] = "Expect buffering even in HD." t["gaming", "great"] = "Low, steady latency for competitive play." t["gaming", "ok"] = "Fine for most games; fast shooters may feel it." t["gaming", "limited"] = "Noticeable delay in fast-paced games." t["gaming", "poor"] = "High or unstable latency will cause lag." t["calls", "great"] = "HD group calls and screen sharing." t["calls", "ok"] = "Reliable calls; HD may drop on busy moments." t["calls", "limited"] = "Audio is fine; video may freeze." t["calls", "poor"] = "Calls are likely to break up." t["hosting", "great"] = "Serve sites, game servers and streams to many users." t["hosting", "ok"] = "Good for personal apps, demos and small game servers." t["hosting", "limited"] = "Enough for light dashboards and remote access." t["hosting", "poor"] = "Upload is too low to serve others comfortably." use("4K streaming", stream, t["stream", stream]) use("Online gaming", gaming, t["gaming", gaming] (calm ? "" : " Bufferbloat adds lag when the line is busy.")) use("Video calls", calls, t["calls", calls]) use("Hosting from home", hosting, t["hosting", hosting]) printf "\n %s%-13s%s%s\n", bold, "Full report", r, url printf " %s%-13s%s%sabout %s%s\n\n", bold, "Data used", r, dim, size(bytes), r }' } main() { parse_args "$@" setup_terminal check_tools JOBS='' TICKS=0 SEQ=0 BYTES=0 TMP='' trap cleanup EXIT trap interrupted INT TERM HUP TMP=$(mktemp -d "${TMPDIR:-/tmp}/ltn-speed.XXXXXX" 2>/dev/null) || die "Could not create a temporary directory." CURL_OPTS="-s --connect-timeout 5 $IPV" ID=$(LC_ALL=C awk 'BEGIN { srand(); printf "%d", rand() * 1000000 }')$$ dd if=/dev/urandom of="$TMP/probe" bs=65536 count=32 2>/dev/null || die "Could not prepare the upload data." [ "$TTY" = 1 ] && printf '\033[?25l' header fetch_meta measure_latency transfer_phase down Download transfer_phase up Upload done_line '' summary exit 0 } main "$@"