# Localtonet Speed Test for the terminal: download, upload, ping, jitter and bufferbloat. # # irm https://speed.localtonet.com/ps | iex # & ([scriptblock]::Create((irm https://speed.localtonet.com/ps))) -Json # # Runs in Windows PowerShell 5.1, which ships with Windows 10 and 11, and in PowerShell 7 on any system. # Nothing is installed and results are not sent anywhere. On macOS and Linux this works too: # curl -fsSL https://speed.localtonet.com/sh | sh # # The file is plain ASCII on purpose: Windows PowerShell may decode a download with a legacy code page. # Everything runs inside one script block, so nothing is left behind in the caller's session. & { param([switch]$Json, [switch]$NoColor) $ErrorActionPreference = 'Stop' $endpoint = 'https://speed.cloudflare.com' # speed.localtonet.com fills in the configured endpoint; a raw copy of this file uses the default. if ($endpoint.Contains('{{')) { $endpoint = 'https://speed.cloudflare.com' } $site = 'https://speed.localtonet.com' $version = '1.0.0' if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { Write-Host 'This PowerShell session runs in Constrained Language Mode, which blocks the speed test. Use https://speed.localtonet.com in a browser instead.' -ForegroundColor Red return } # The measurement engine is the browser test's algorithm in C#: PowerShell alone cannot read several # connections at once. It is written for C# 5, the compiler Windows PowerShell 5.1 uses. if (-not ('LtnSpeed100.Engine' -as [type])) { $source = @' using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace LtnSpeed100 { public sealed class Engine : IDisposable { const int LatencySamples = 20; readonly string baseUrl; readonly string referer; readonly HttpClient client; readonly CancellationTokenSource stopper = new CancellationTokenSource(); readonly object sync = new object(); readonly byte[] block = new byte[1 << 20]; readonly int savedConnectionLimit; readonly bool savedExpect100; string measurementId; long sequence; long bytesUsed; Thread runner; string phase = "idle"; string error; double ping = double.NaN, pingLow = double.NaN, jitter = double.NaN; double liveBps, progress; double down = double.NaN, up = double.NaN, loadedDown = double.NaN, loadedUp = double.NaN; public Engine(string endpoint, string referer, string userAgent) { baseUrl = endpoint.TrimEnd('/'); this.referer = referer; // Windows PowerShell allows two connections per host and waits for 100-continue by default. savedConnectionLimit = ServicePointManager.DefaultConnectionLimit; savedExpect100 = ServicePointManager.Expect100Continue; ServicePointManager.DefaultConnectionLimit = Math.Max(savedConnectionLimit, 32); ServicePointManager.Expect100Continue = false; try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; } catch (NotSupportedException) { } var handler = new HttpClientHandler(); handler.AutomaticDecompression = DecompressionMethods.None; client = new HttpClient(handler); client.Timeout = TimeSpan.FromSeconds(60); client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", userAgent); // Random bytes cannot be compressed on the path. new Random().NextBytes(block); } public string Phase { get { lock (sync) return phase; } } public string Error { get { lock (sync) return error; } } public double Ping { get { lock (sync) return ping; } } public double PingLow { get { lock (sync) return pingLow; } } public double Jitter { get { lock (sync) return jitter; } } public double LiveBps { get { lock (sync) return liveBps; } } public double Progress { get { lock (sync) return progress; } } public double Down { get { lock (sync) return down; } } public double Up { get { lock (sync) return up; } } public double LoadedDown { get { lock (sync) return loadedDown; } } public double LoadedUp { get { lock (sync) return loadedUp; } } public long BytesUsed { get { return Interlocked.Read(ref bytesUsed); } } public string FetchMeta() { try { var request = new HttpRequestMessage(HttpMethod.Get, baseUrl + "/meta"); request.Headers.Referrer = new Uri(referer); using (var timeout = new CancellationTokenSource(5000)) using (var response = client.SendAsync(request, timeout.Token).Result) return response.IsSuccessStatusCode ? response.Content.ReadAsStringAsync().Result : null; } catch (Exception) { return null; } } public void Start() { measurementId = new Random().Next(100000000, 999999999).ToString(CultureInfo.InvariantCulture); runner = new Thread(Run); runner.IsBackground = true; runner.Start(); } public void Dispose() { stopper.Cancel(); if (runner != null) runner.Join(3000); client.Dispose(); ServicePointManager.DefaultConnectionLimit = savedConnectionLimit; ServicePointManager.Expect100Continue = savedExpect100; } void SetPhase(string value) { lock (sync) { phase = value; liveBps = 0; progress = 0; } } void Run() { var token = stopper.Token; try { SetPhase("latency"); MeasureLatency(token); SetPhase("down"); double loaded; double bps = TransferPhase(false, token, out loaded); lock (sync) { down = bps; loadedDown = loaded; } SetPhase("up"); bps = TransferPhase(true, token, out loaded); lock (sync) { up = bps; loadedUp = loaded; } SetPhase("done"); } catch (Exception e) { if (token.IsCancellationRequested) { SetPhase("stopped"); return; } lock (sync) { error = e.GetBaseException().Message; phase = "error"; } } } string Url(string path, string query) { long n = Interlocked.Increment(ref sequence); return baseUrl + path + "?" + (query == null ? "" : query + "&") + "measId=" + measurementId + n.ToString(CultureInfo.InvariantCulture); } static void Sleep(int ms, CancellationToken token) { token.WaitHandle.WaitOne(ms); token.ThrowIfCancellationRequested(); } // Processing time the server reports in Server-Timing is not network latency. double ProbeLatency(CancellationToken token) { var watch = Stopwatch.StartNew(); using (var response = client.GetAsync(Url("/__down", "bytes=0"), HttpCompletionOption.ResponseHeadersRead, token).Result) { double ms = watch.Elapsed.TotalMilliseconds; response.EnsureSuccessStatusCode(); response.Content.ReadAsByteArrayAsync().Wait(); IEnumerable values; if (response.Headers.TryGetValues("Server-Timing", out values)) { double server = 0; foreach (var value in values) foreach (Match match in Regex.Matches(value, @"dur=([0-9.]+)")) server += double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); if (server > 0 && server < ms) ms -= server; } return ms; } } void MeasureLatency(CancellationToken token) { ProbeLatency(token); // Warm-up: opens the connection. var samples = new List(); for (int i = 0; i < LatencySamples; i++) { samples.Add(ProbeLatency(token)); double total = 0; for (int k = 1; k < samples.Count; k++) total += Math.Abs(samples[k] - samples[k - 1]); lock (sync) { ping = Median(samples); pingLow = Min(samples); jitter = samples.Count > 1 ? total / (samples.Count - 1) : 0; } Sleep(25, token); } } double TransferPhase(bool upload, CancellationToken token, out double loaded) { loaded = double.NaN; var samples = new List(); using (var pings = CancellationTokenSource.CreateLinkedTokenSource(token)) { var pinger = new Thread(() => { try { Sleep(1000, pings.Token); while (!pings.IsCancellationRequested) { double ms = ProbeLatency(pings.Token); lock (samples) samples.Add(ms); Sleep(400, pings.Token); } } catch (Exception) { /* Ends with its transfer phase. */ } }); pinger.IsBackground = true; pinger.Start(); try { return MeasureThroughput(upload, token); } finally { pings.Cancel(); pinger.Join(3000); lock (samples) loaded = Median(samples); } } } double MeasureThroughput(bool upload, CancellationToken outer) { const int minMs = 5000, maxMs = 11000; // Upload writes reach the socket in bursts, so its rate uses a wider window. int windowMs = upload ? 2000 : 1000; double minBytes = upload ? 125e3 : 250e3, maxBytes = upload ? 25e6 : 50e6; var moved = new long[1]; var samples = new List { new double[] { 0, 0 } }; var rates = new List(); var threads = new List(); double estimate = 0; int streams = 0; Exception fatal = null; using (var local = CancellationTokenSource.CreateLinkedTokenSource(outer)) { var token = local.Token; Action count = n => { Interlocked.Add(ref moved[0], n); Interlocked.Add(ref bytesUsed, n); }; // Requests of about half a second per stream keep the line busy without overshooting the time budget. Func nextSize = () => (long)Math.Round(Math.Min(maxBytes, Math.Max(minBytes, estimate / 8 * 0.5 / Math.Max(1, streams)))); ThreadStart stream = () => { int failures = 0; while (!token.IsCancellationRequested) { try { if (upload) UploadOnce(nextSize(), count, token); else DownloadOnce(nextSize(), count, token); failures = 0; } catch (Exception e) { if (token.IsCancellationRequested) return; if (++failures >= 4) { fatal = e; local.Cancel(); return; } token.WaitHandle.WaitOne(250); } } }; Action startStream = () => { var thread = new Thread(stream); thread.IsBackground = true; streams++; threads.Add(thread); thread.Start(); }; var watch = Stopwatch.StartNew(); double elapsed = 0; startStream(); try { while (true) { Sleep(100, outer); if (fatal != null) throw fatal; elapsed = watch.Elapsed.TotalMilliseconds; samples.Add(new double[] { elapsed, Interlocked.Read(ref moved[0]) }); double bps = WindowRate(samples, Math.Min(windowMs, elapsed)); if (bps > 0) estimate = bps; rates.Add(new double[] { elapsed, bps }); int wanted = bps > 400e6 ? 6 : bps > 100e6 ? 4 : bps > 25e6 ? 3 : bps > 4e6 ? 2 : 1; while (streams < wanted && elapsed > 500) startStream(); bool done = elapsed >= maxMs || (elapsed >= minMs && IsStable(rates, elapsed)); lock (sync) { liveBps = bps; progress = done ? 1 : elapsed / maxMs; } if (done) break; } } finally { local.Cancel(); foreach (var thread in threads) thread.Join(2000); } if (Interlocked.Read(ref moved[0]) == 0) throw new InvalidOperationException("No data could be transferred."); return Summarize(rates, elapsed); } } void DownloadOnce(long bytes, Action count, CancellationToken token) { string url = Url("/__down", "bytes=" + bytes.ToString(CultureInfo.InvariantCulture)); using (var response = client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token).Result) { response.EnsureSuccessStatusCode(); using (var body = response.Content.ReadAsStreamAsync().Result) { var buffer = new byte[65536]; int n; while ((n = body.Read(buffer, 0, buffer.Length)) > 0) { count(n); if (token.IsCancellationRequested) return; } } } } void UploadOnce(long bytes, Action count, CancellationToken token) { var content = new UploadContent(block, bytes, count, token); content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); using (var response = client.PostAsync(Url("/__up", null), content, token).Result) response.EnsureSuccessStatusCode(); } sealed class UploadContent : HttpContent { readonly byte[] block; readonly long length; readonly Action count; readonly CancellationToken token; public UploadContent(byte[] block, long length, Action count, CancellationToken token) { this.block = block; this.length = length; this.count = count; this.token = token; } protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) { // Bytes count as they are handed to the connection, like the browser's upload progress events. for (long sent = 0; sent < length; ) { token.ThrowIfCancellationRequested(); int n = (int)Math.Min(65536, length - sent); stream.Write(block, (int)(sent % (block.Length - 65536)), n); sent += n; count(n); } return Task.FromResult(0); } protected override bool TryComputeLength(out long size) { size = length; return true; } } static double WindowRate(List samples, double windowMs) { var last = samples[samples.Count - 1]; int index = samples.Count - 2; while (index > 0 && last[0] - samples[index - 1][0] <= windowMs) index--; var first = samples[Math.Max(0, index)]; double elapsed = last[0] - first[0]; return elapsed > 0 ? (last[1] - first[1]) * 8000 / elapsed : 0; } static bool IsStable(List rates, double now) { var recent = new List(); foreach (var rate in rates) if (rate[0] >= now - 2000) recent.Add(rate[1]); if (recent.Count < 15) return false; double average = Mean(recent), max = double.MinValue, min = double.MaxValue; foreach (var value in recent) { max = Math.Max(max, value); min = Math.Min(min, value); } return average > 0 && (max - min) / average < 0.1; } // Skip TCP slow start, then use a trimmed mean of the windowed rates. static double Summarize(List rates, double duration) { double cutoff = Math.Max(1000, duration * 0.25); var values = new List(); foreach (var rate in rates) if (rate[0] >= cutoff) values.Add(rate[1]); if (values.Count < 5) { values.Clear(); foreach (var rate in rates) values.Add(rate[1]); } values.Sort(); int start = (int)Math.Floor(values.Count * 0.3); int end = Math.Min(values.Count, Math.Max(start + 1, (int)Math.Ceiling(values.Count * 0.9))); return Mean(values.GetRange(start, end - start)); } static double Mean(List values) { if (values.Count == 0) return double.NaN; double total = 0; foreach (var value in values) total += value; return total / values.Count; } static double Min(List values) { double min = double.NaN; foreach (var value in values) if (double.IsNaN(min) || value < min) min = value; return min; } static double Median(List values) { if (values.Count == 0) return double.NaN; var sorted = new List(values); sorted.Sort(); int middle = sorted.Count / 2; return sorted.Count % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } } } '@ # PowerShell 7 warns that ServicePointManager is obsolete there; warnings would otherwise fail the build. $compile = @{ TypeDefinition = $source; Language = 'CSharp'; IgnoreWarnings = $true } if ($PSVersionTable.PSEdition -ne 'Core') { $compile.ReferencedAssemblies = @('System.Net.Http') } try { Add-Type @compile } catch { Write-Host "The speed test could not start: $($_.Exception.Message)" -ForegroundColor Red return } } # ---------- Output $inv = [Globalization.CultureInfo]::InvariantCulture $canLive = -not $Json -and $Host.Name -eq 'ConsoleHost' try { if ([Console]::IsOutputRedirected) { $canLive = $false } } catch { } $useColor = -not $Json -and -not $NoColor -and -not $env:NO_COLOR $onWindows = $PSVersionTable.PSEdition -ne 'Core' -or $IsWindows # The classic Windows console font has no braille or check marks; Windows Terminal and other systems do. $richFont = -not $onWindows -or $env:WT_SESSION -or $env:TERM_PROGRAM $full = [string][char]0x2588 $empty = [string][char]0x2591 $sep = [string][char]0x00B7 $arrowDown = [string][char]0x2193 $arrowUp = [string][char]0x2191 if ($richFont) { $marks = @{ great = [string][char]0x2714; ok = [string][char]0x2714; limited = '~'; poor = [string][char]0x2718 } $spinner = @(0x280B, 0x2819, 0x2839, 0x2838, 0x283C, 0x2834, 0x2826, 0x2827, 0x2807, 0x280F) | ForEach-Object { [string][char]$_ } } else { $marks = @{ great = [string][char]0x221A; ok = [string][char]0x221A; limited = '~'; poor = 'x' } $spinner = @('|', '/', '-', '\') } $bar = 24 try { $bar = [Math]::Max(8, [Math]::Min(30, $Host.UI.RawUI.WindowSize.Width - 35)) } catch { } $lastLength = 0 function Fmt([string]$format) { [string]::Format($inv, $format, $args) } # A line is a flat list of text, color pairs. A transient line is redrawn in place by the next one. function Show-Line([object[]]$parts, [switch]$Transient) { if ($Json -or ($Transient -and -not $canLive)) { return } $text = '' if ($canLive) { Write-Host "`r" -NoNewline } for ($i = 0; $i -lt $parts.Count; $i += 2) { $segment = [string]$parts[$i] $text += $segment if ($useColor -and $parts[$i + 1]) { Write-Host $segment -NoNewline -ForegroundColor $parts[$i + 1] } else { Write-Host $segment -NoNewline } } if ($canLive -and $script:lastLength -gt $text.Length) { Write-Host (' ' * ($script:lastLength - $text.Length)) -NoNewline } if ($Transient) { $script:lastLength = $text.Length } else { Write-Host ''; $script:lastLength = 0 } } function Format-Speed([double]$bps) { if (-not ($bps -gt 0)) { return '--' } $m = $bps / 1e6 if ($m -lt 10) { Fmt '{0:0.00}' $m } elseif ($m -lt 100) { Fmt '{0:0.0}' $m } else { Fmt '{0:0}' $m } } function Format-Ms([double]$ms) { if ([double]::IsNaN($ms)) { return '--' } if ($ms -lt 10) { Fmt '{0:0.0}' $ms } else { Fmt '{0:0}' $ms } } function Label([string]$name) { ' ' + $name.PadRight(13) } function Speed-Parts([string]$name, [double]$bps) { $m = $bps / 1e6 $x = 0 # 1.0, not 1: with an integer first argument PowerShell picks Math.Min(int, int) and rounds the ratio. if ($m -gt 0) { $x = [Math]::Min(1.0, [Math]::Log(1 + $m) / [Math]::Log(1001)) } $n = [int][Math]::Floor($x * $bar + 0.5) @((Label $name), $null, ($full * $n), 'Cyan', ($empty * ($bar - $n)), 'DarkGray', (' ' + (Format-Speed $bps).PadLeft(7) + ' Mbps'), $null) } function Ping-Parts([double]$ping, [double]$jitter, [double]$low) { @((Label 'Ping'), $null, ((Format-Ms $ping) + ' ms '), $null, 'jitter ', 'DarkGray', ((Format-Ms $jitter) + ' ms '), $null, 'low ', 'DarkGray', ((Format-Ms $low) + ' ms'), $null) } # ---------- Test $engine = New-Object LtnSpeed100.Engine -ArgumentList $endpoint, "$site/", "localtonet-speedtest/$version (PowerShell)" $finished = $false $cursorHidden = $false try { if ($canLive) { try { [Console]::CursorVisible = $false; $cursorHidden = $true } catch { } } if (-not $Json) { Write-Host '' Show-Line @(' Localtonet', 'Cyan', ' Speed Test', $null, ' speed.localtonet.com', 'DarkGray') Write-Host '' } Show-Line @((Label 'Server'), $null, 'connecting...', 'DarkGray') -Transient $meta = $null $raw = $engine.FetchMeta() if ($raw) { try { $meta = $raw | ConvertFrom-Json } catch { } } $colo = ''; $coloCity = ''; $asn = $null; $org = ''; $city = ''; $country = '' if ($meta) { if ($meta.colo) { $colo = [string]$meta.colo.iata; $coloCity = [string]$meta.colo.city } $asn = $meta.asn; $org = [string]$meta.asOrganization; $city = [string]$meta.city; $country = [string]$meta.country } # Server names by colo code (";IST=Istanbul;FRA=Frankfurt;"), filled in from SpeedTestColos.cs without accents. # Without them the server shows under Cloudflare's own city for it. $coloNames = ';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=Aracatuba;ASK=Yamoussoukro;ASU=Asuncion;ATH=Athens;ATL=Atlanta;AUS=Austin;BAH=Manama;BAQ=Barranquilla;BBI=Bhubaneswar;BCN=Barcelona;BDQ=Vadodara;BEG=Belgrade;BEL=Belem;BER=Berlin;BEY=Beirut;BGI=Bridgetown;BGR=Bangor;BGW=Baghdad;BKK=Bangkok;BLR=Bengaluru;BNA=Nashville;BNE=Brisbane;BOD=Bordeaux;BOG=Bogota;BOM=Mumbai;BOS=Boston;BRU=Brussels;BSB=Brasilia;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=Cacador;CGB=Cuiaba;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=Cordoba;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=Dusseldorf;DXB=Dubai;DYU=Dushanbe;EBB=Kampala;EBL=Erbil;ESB=Ankara;EVN=Yerevan;EWR=Newark;EZE=Buenos Aires;FCO=Rome;FLN=Florianopolis;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=Sao Paulo;GUA=Guatemala City;GUM=Guam;GVA=Geneva;GYD=Baku;GYE=Guayaquil;GYN=Goiania;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=Reykjavik;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=Medellin;MEL=Melbourne;MEM=Memphis;MEX=Mexico City;MFM=Macau;MIA=Miami;MLA=Valletta;MLE=Male;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=Noumea;NQN=Neuquen;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=Queretaro;QWJ=Americana;RAO=Ribeirao 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=Sao Jose dos Campos;SJO=San Jose;SJP=Sao Jose 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=Uberlandia;UDR=Udaipur;UIO=Quito;ULN=Ulaanbaatar;URT=Surat Thani;VCP=Campinas;VIE=Vienna;VIX=Vitoria;VNO=Vilnius;VTE=Vientiane;WAW=Warsaw;WDH=Windhoek;WLG=Wellington;WRO=Wroclaw;XAP=Chapeco;XNH=Nasiriyah;YHZ=Halifax;YUL=Montreal;YVR=Vancouver;YWG=Winnipeg;YXE=Saskatoon;YYC=Calgary;YYZ=Toronto;ZAG=Zagreb;ZDM=Ramallah;ZRH=Zurich;' $colos = @{} if (-not $coloNames.Contains('{{')) { foreach ($pair in $coloNames.Split(';')) { $parts = $pair.Split('=') if ($parts.Count -eq 2) { $colos[$parts[0]] = $parts[1] } } } $serverName = $coloCity if ($colo -and $colos.ContainsKey($colo)) { $serverName = $colos[$colo] } if ($colo) { $server = "$serverName ($colo)" } else { $server = ([Uri]$endpoint).Host } Show-Line @((Label 'Server'), $null, $server, $null) $network = @() if ($org) { $network += $org } if ($asn) { $network += "AS$asn" } $place = @($city, $country) | Where-Object { $_ } if ($place) { $network += ($place -join ', ') } if ($network) { Show-Line @((Label 'Network'), $null, ($network -join " $sep "), $null) } if (-not $Json) { Write-Host '' } $engine.Start() $shown = 'latency' $tick = 0 while ($true) { $phase = $engine.Phase if ($phase -ne $shown -and $phase -ne 'idle') { if ($phase -ne 'error' -and $phase -ne 'stopped') { switch ($shown) { 'latency' { Show-Line (Ping-Parts $engine.Ping $engine.Jitter $engine.PingLow) } 'down' { Show-Line (Speed-Parts 'Download' $engine.Down) } 'up' { Show-Line (Speed-Parts 'Upload' $engine.Up) } } } $shown = $phase } if ($phase -eq 'done' -or $phase -eq 'error' -or $phase -eq 'stopped') { break } if ($canLive) { $frame = $spinner[$tick % $spinner.Count] if ($phase -eq 'down' -or $phase -eq 'up') { $name = 'Download' if ($phase -eq 'up') { $name = 'Upload' } Show-Line ((Speed-Parts $name $engine.LiveBps) + @(" $frame", 'Cyan')) -Transient } else { Show-Line ((Ping-Parts $engine.Ping $engine.Jitter $engine.PingLow) + @(" $frame", 'Cyan')) -Transient } } Start-Sleep -Milliseconds 120 $tick++ } if ($phase -eq 'error') { if ($canLive) { Show-Line @('', $null) -Transient; Write-Host "`r" -NoNewline } $message = "The speed test could not finish: $($engine.Error) Check your connection and try again." if ($Json) { [Console]::Error.WriteLine($message) } else { Write-Host " $message" -ForegroundColor Red; Write-Host '' } $finished = $true return } # ---------- Result, with the same grading as the browser test $down = $engine.Down; $up = $engine.Up; $ping = $engine.Ping; $jitter = $engine.Jitter $loadedDown = $engine.LoadedDown; $loadedUp = $engine.LoadedUp $dm = $down / 1e6; $um = $up / 1e6 $loaded = @($loadedDown, $loadedUp) | Where-Object { -not [double]::IsNaN($_) } | Measure-Object -Maximum $added = [double]::NaN; $grade = '' if ($loaded.Count) { $added = [Math]::Max(0.0, $loaded.Maximum - $ping) if ($added -lt 5) { $grade = 'A+' } elseif ($added -lt 30) { $grade = 'A' } elseif ($added -lt 60) { $grade = 'B' } elseif ($added -lt 200) { $grade = 'C' } elseif ($added -lt 400) { $grade = 'D' } else { $grade = 'F' } } $calm = [double]::IsNaN($added) -or $added -lt 60 $timestamp = [DateTimeOffset]::UtcNow $url = Fmt '{0}/#r=1&d={1:0.00}&u={2:0.00}&p={3:0.0}&j={4:0.0}&t={5}' $site $dm $um $ping $jitter $timestamp.ToUnixTimeSeconds() if (-not [double]::IsNaN($loadedDown)) { $url += Fmt '&ld={0:0}' $loadedDown } if (-not [double]::IsNaN($loadedUp)) { $url += Fmt '&lu={0:0}' $loadedUp } if ($colo -cmatch '^[A-Z]{3}$') { $url += "&s=$colo" } if ($Json) { $number = { param($value, $digits) if ([double]::IsNaN($value)) { $null } else { [Math]::Round($value, $digits) } } $result = [ordered]@{ download_mbps = & $number $dm 2 upload_mbps = & $number $um 2 ping_ms = & $number $ping 1 jitter_ms = & $number $jitter 1 ping_low_ms = & $number $engine.PingLow 1 loaded_latency_ms = [ordered]@{ download = & $number $loadedDown 0; upload = & $number $loadedUp 0 } bufferbloat = $(if ($grade) { $grade } else { $null }) server = [ordered]@{ colo = $(if ($colo) { $colo } else { $null }); city = $(if ($serverName) { $serverName } else { $null }) } network = [ordered]@{ asn = $asn; organization = $(if ($org) { $org } else { $null }) } data_used_bytes = $engine.BytesUsed timestamp = $timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", $inv) report_url = $url } $finished = $true # Windows PowerShell writes every & as a unicode escape (backslash, u0026); both spellings are the same JSON. ($result | ConvertTo-Json -Compress -Depth 3).Replace(([string][char]92) + 'u0026', '&') return } Write-Host '' if ($grade) { Show-Line @((Label 'Under load'), $null, "$arrowDown $(Format-Ms $loadedDown) ms $arrowUp $(Format-Ms $loadedUp) ms", $null) $gradeColor = 'Red' if ($grade.StartsWith('A')) { $gradeColor = 'Green' } elseif ($grade -eq 'B' -or $grade -eq 'C') { $gradeColor = 'Yellow' } $note = 'Enabling SQM or adaptive QoS on your router usually fixes this.' if ($added -lt 30) { $note = 'Games and calls stay smooth while the line is busy.' } elseif ($added -lt 200) { $note = 'Heavy uploads or downloads can cause noticeable lag.' } Show-Line @((Label 'Bufferbloat'), $null, $grade.PadRight(2), $gradeColor, " +$(Format-Ms $added) ms. $note", 'DarkGray') } $title = 'Slow connection' if ($dm -ge 200 -and $ping -le 30) { $title = 'Fast and responsive' } elseif ($dm -ge 100) { $title = 'Fast connection' } elseif ($dm -ge 25) { $title = 'Solid everyday connection' } elseif ($dm -ge 5) { $title = 'Basic connection' } if ($grade -eq 'D' -or $grade -eq 'F') { $title += ', but laggy under load' } Write-Host '' Show-Line @(" $title", $null) function Level([bool]$great, [bool]$ok, [bool]$limited) { if ($great) { 'great' } elseif ($ok) { 'ok' } elseif ($limited) { 'limited' } else { 'poor' } } $stream = Level ($dm -ge 25) ($dm -ge 15) ($dm -ge 5) $gaming = Level ($ping -le 30 -and $jitter -le 10 -and $calm) ($ping -le 60 -and $jitter -le 20) ($ping -le 100 -and $jitter -le 40) $calls = Level ($dm -ge 10 -and $um -ge 5 -and $ping -le 80 -and $jitter -le 30) ($dm -ge 4 -and $um -ge 2 -and $ping -le 150) ($um -ge 1) $hosting = Level ($um -ge 50 -and $ping -le 60) ($um -ge 10) ($um -ge 3) $streamGreat = 'Smooth 4K HDR on one screen.' if ($dm -ge 75) { $streamGreat = 'Several 4K streams at once, with room to spare.' } $gamingNote = '' if (-not $calm) { $gamingNote = ' Bufferbloat adds lag when the line is busy.' } $uses = @( @('4K streaming', $stream, @{ great = $streamGreat; ok = '4K plays, with little headroom for other devices.'; limited = 'HD works well; 4K may buffer.'; poor = 'Expect buffering even in HD.' }), @('Online gaming', $gaming, @{ great = 'Low, steady latency for competitive play.'; ok = 'Fine for most games; fast shooters may feel it.'; limited = 'Noticeable delay in fast-paced games.'; poor = 'High or unstable latency will cause lag.' }, $gamingNote), @('Video calls', $calls, @{ great = 'HD group calls and screen sharing.'; ok = 'Reliable calls; HD may drop on busy moments.'; limited = 'Audio is fine; video may freeze.'; poor = 'Calls are likely to break up.' }), @('Hosting from home', $hosting, @{ great = 'Serve sites, game servers and streams to many users.'; ok = 'Good for personal apps, demos and small game servers.'; limited = 'Enough for light dashboards and remote access.'; poor = 'Upload is too low to serve others comfortably.' }) ) $levelColors = @{ great = 'Green'; ok = $null; limited = 'Yellow'; poor = 'Red' } foreach ($use in $uses) { $level = $use[1] Show-Line @(' ', $null, $marks[$level], $levelColors[$level], (' ' + $use[0].PadRight(18) + ' '), $null, ($use[2][$level] + $use[3]), 'DarkGray') } $bytes = $engine.BytesUsed if ($bytes -ge 1e9) { $used = Fmt '{0:0.00} GB' ($bytes / 1e9) } elseif ($bytes -ge 1e6) { $used = Fmt '{0:0} MB' ($bytes / 1e6) } else { $used = Fmt '{0:0} KB' ($bytes / 1e3) } Write-Host '' Show-Line @((Label 'Full report'), $null, $url, 'Cyan') Show-Line @((Label 'Data used'), $null, "about $used", 'DarkGray') Write-Host '' $finished = $true } finally { $engine.Dispose() if ($cursorHidden) { try { [Console]::CursorVisible = $true } catch { } } if (-not $finished -and -not $Json) { # Clears the half-drawn live line, then says why the test ended. if ($canLive) { Show-Line @('', $null) -Transient; Write-Host "`r" -NoNewline } Write-Host ' Test stopped.' Write-Host '' } } } @args