metar-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
metar.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""metar-cli — METAR + TAF terminal dashboard"""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import termios
|
|
9
|
+
import tty
|
|
10
|
+
import requests
|
|
11
|
+
from datetime import datetime, timezone, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.columns import Columns
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
CONFIG_FILE = Path.home() / ".config" / "metar" / "config"
|
|
21
|
+
FALLBACK_ICAO = "MMML"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_default_icao():
|
|
25
|
+
if icao := os.environ.get("METAR_ICAO"):
|
|
26
|
+
return icao.upper()
|
|
27
|
+
if CONFIG_FILE.exists():
|
|
28
|
+
val = CONFIG_FILE.read_text().strip()
|
|
29
|
+
if val:
|
|
30
|
+
return val.upper()
|
|
31
|
+
return FALLBACK_ICAO
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_default_icao(icao):
|
|
35
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
CONFIG_FILE.write_text(icao.upper() + "\n")
|
|
37
|
+
console.print(f"Default station set to [bold]{icao.upper()}[/bold] ({CONFIG_FILE})")
|
|
38
|
+
METAR_URL = "https://aviationweather.gov/api/data/metar"
|
|
39
|
+
TAF_URL = "https://aviationweather.gov/api/data/taf"
|
|
40
|
+
ASOS_URL = "https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py"
|
|
41
|
+
|
|
42
|
+
FR_STYLES = {
|
|
43
|
+
"VFR": ("green", "bold white on green"),
|
|
44
|
+
"MVFR": ("blue", "bold white on blue"),
|
|
45
|
+
"IFR": ("red", "bold white on red"),
|
|
46
|
+
"LIFR": ("magenta", "bold white on magenta"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ROSE_GRID = [
|
|
50
|
+
["↖", "↑", "↗"],
|
|
51
|
+
["←", "·", "→"],
|
|
52
|
+
["↙", "↓", "↘"],
|
|
53
|
+
]
|
|
54
|
+
ROSE_POS = {
|
|
55
|
+
"N": (2, 1), "NE": (2, 0), "E": (1, 0), "SE": (0, 0),
|
|
56
|
+
"S": (0, 1), "SW": (0, 2), "W": (1, 2), "NW": (2, 2),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
CHANGE_STYLES = {
|
|
60
|
+
"FM": "bold cyan",
|
|
61
|
+
"BECMG": "bold yellow",
|
|
62
|
+
"TEMPO": "bold magenta",
|
|
63
|
+
"PROB30": "dim magenta",
|
|
64
|
+
"PROB40": "dim magenta",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def deg_to_cardinal(deg):
|
|
69
|
+
dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
|
70
|
+
return dirs[round(float(deg) / 45) % 8]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_rose(wdir, wspd, color):
|
|
74
|
+
active = ROSE_POS[deg_to_cardinal(wdir)] if wdir and wspd > 0 else None
|
|
75
|
+
t = Text()
|
|
76
|
+
for r in range(3):
|
|
77
|
+
for c in range(3):
|
|
78
|
+
ch = ROSE_GRID[r][c]
|
|
79
|
+
style = f"bold {color}" if active and (r, c) == active else "dim"
|
|
80
|
+
t.append(ch, style=style)
|
|
81
|
+
t.append(" ")
|
|
82
|
+
if r < 2:
|
|
83
|
+
t.append("\n")
|
|
84
|
+
return t
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
SPARKS = "▁▂▃▄▅▆▇█"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sparkline(values):
|
|
91
|
+
clean = [v for v in values if v is not None]
|
|
92
|
+
if len(clean) < 2:
|
|
93
|
+
return "─" * len(values)
|
|
94
|
+
lo, hi = min(clean), max(clean)
|
|
95
|
+
if lo == hi:
|
|
96
|
+
return SPARKS[3] * len(values)
|
|
97
|
+
return "".join(
|
|
98
|
+
SPARKS[round((v - lo) / (hi - lo) * 7)] if v is not None else " "
|
|
99
|
+
for v in values
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fetch_metar(icao):
|
|
104
|
+
resp = requests.get(METAR_URL, params={"ids": icao, "format": "json"}, timeout=10)
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
if not resp.content:
|
|
107
|
+
raise ValueError(f"No METAR data for {icao}")
|
|
108
|
+
data = resp.json()
|
|
109
|
+
if not data:
|
|
110
|
+
raise ValueError(f"No METAR data for {icao}")
|
|
111
|
+
return data[0]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fetch_taf(icao):
|
|
115
|
+
resp = requests.get(TAF_URL, params={"ids": icao, "format": "json"}, timeout=10)
|
|
116
|
+
resp.raise_for_status()
|
|
117
|
+
if not resp.content:
|
|
118
|
+
return None
|
|
119
|
+
data = resp.json()
|
|
120
|
+
return data[0] if data else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_history(icao, hours=6):
|
|
124
|
+
now = datetime.now(tz=timezone.utc)
|
|
125
|
+
start = now - timedelta(hours=hours)
|
|
126
|
+
resp = requests.get(ASOS_URL, params={
|
|
127
|
+
"station": icao, "data": "tmpf,drct,sknt,alti",
|
|
128
|
+
"year1": start.year, "month1": start.month, "day1": start.day, "hour1": start.hour,
|
|
129
|
+
"year2": now.year, "month2": now.month, "day2": now.day, "hour2": now.hour,
|
|
130
|
+
"tz": "UTC", "format": "onlycomma", "latlon": "no", "missing": "null",
|
|
131
|
+
}, timeout=10)
|
|
132
|
+
records = []
|
|
133
|
+
for line in resp.text.strip().splitlines()[1:]:
|
|
134
|
+
parts = line.split(",")
|
|
135
|
+
if len(parts) < 6:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
tmpf = float(parts[2]) if parts[2] != "null" else None
|
|
139
|
+
wspd = float(parts[4]) if parts[4] != "null" else None
|
|
140
|
+
alti = float(parts[5]) if parts[5] != "null" else None
|
|
141
|
+
records.append({
|
|
142
|
+
"temp": (tmpf - 32) * 5 / 9 if tmpf is not None else None,
|
|
143
|
+
"wspd": wspd,
|
|
144
|
+
"inhg": alti,
|
|
145
|
+
})
|
|
146
|
+
except (ValueError, IndexError):
|
|
147
|
+
continue
|
|
148
|
+
return records
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def age_str(obs_time):
|
|
152
|
+
try:
|
|
153
|
+
dt = datetime.fromtimestamp(int(obs_time), tz=timezone.utc)
|
|
154
|
+
mins = int((datetime.now(tz=timezone.utc) - dt).total_seconds() / 60)
|
|
155
|
+
return f"{mins}m ago"
|
|
156
|
+
except Exception:
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fmt_time(ts):
|
|
161
|
+
try:
|
|
162
|
+
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
|
163
|
+
return dt.strftime("%d/%Hz")
|
|
164
|
+
except Exception:
|
|
165
|
+
return "??Z"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_ceiling(clouds):
|
|
169
|
+
for layer in (clouds or []):
|
|
170
|
+
if layer.get("cover") in ("BKN", "OVC"):
|
|
171
|
+
return f"{layer['base']:,} ft"
|
|
172
|
+
return "None"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_WX_INTENSITY = {"-": "light", "+": "heavy"}
|
|
176
|
+
_WX_DESCRIPTOR = {
|
|
177
|
+
"TS": "thunderstorm", "FZ": "freezing", "BL": "blowing",
|
|
178
|
+
"DR": "drifting", "BC": "patchy", "MI": "shallow",
|
|
179
|
+
"PR": "partial", "SH": None, # handled as suffix "showers"
|
|
180
|
+
}
|
|
181
|
+
_WX_PHENOM = {
|
|
182
|
+
"DZ": "drizzle", "RA": "rain", "SN": "snow", "SG": "snow grains",
|
|
183
|
+
"IC": "ice", "PL": "pellets", "GR": "hail", "GS": "small hail",
|
|
184
|
+
"UP": "precip", "BR": "mist", "FG": "fog", "FU": "smoke",
|
|
185
|
+
"VA": "ash", "DU": "dust", "SA": "sand", "HZ": "haze",
|
|
186
|
+
"PY": "spray", "PO": "dust whirls", "SQ": "squalls",
|
|
187
|
+
"FC": "funnel cloud", "SS": "sandstorm", "DS": "duststorm",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
def _decode_wx_token(tok):
|
|
191
|
+
i = 0
|
|
192
|
+
nearby = False
|
|
193
|
+
intensity = ""
|
|
194
|
+
descs = []
|
|
195
|
+
phenom = []
|
|
196
|
+
|
|
197
|
+
if tok[i:i+2] == "VC":
|
|
198
|
+
nearby = True
|
|
199
|
+
i += 2
|
|
200
|
+
|
|
201
|
+
if i < len(tok) and tok[i] in "-+":
|
|
202
|
+
intensity = _WX_INTENSITY[tok[i]]
|
|
203
|
+
i += 1
|
|
204
|
+
|
|
205
|
+
while i + 1 < len(tok) + 1:
|
|
206
|
+
code = tok[i:i+2]
|
|
207
|
+
if not code:
|
|
208
|
+
break
|
|
209
|
+
if code in _WX_DESCRIPTOR:
|
|
210
|
+
descs.append(code)
|
|
211
|
+
i += 2
|
|
212
|
+
elif code in _WX_PHENOM:
|
|
213
|
+
phenom.append(_WX_PHENOM[code])
|
|
214
|
+
i += 2
|
|
215
|
+
else:
|
|
216
|
+
i += 1
|
|
217
|
+
|
|
218
|
+
words = []
|
|
219
|
+
if intensity:
|
|
220
|
+
words.append(intensity)
|
|
221
|
+
for d in ("TS", "FZ", "BL", "DR", "BC", "MI", "PR"):
|
|
222
|
+
if d in descs:
|
|
223
|
+
words.append(_WX_DESCRIPTOR[d])
|
|
224
|
+
words.extend(phenom)
|
|
225
|
+
if "SH" in descs:
|
|
226
|
+
words.append("showers")
|
|
227
|
+
if nearby:
|
|
228
|
+
words.append("nearby")
|
|
229
|
+
return " ".join(words)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def decode_wx(wx_str):
|
|
233
|
+
if not wx_str:
|
|
234
|
+
return ""
|
|
235
|
+
return " · ".join(_decode_wx_token(t) for t in wx_str.split() if t)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def hpa_to_inhg(hpa):
|
|
239
|
+
return f"{float(hpa) * 0.02953:.2f}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
MAX_SPARK_POINTS = 12
|
|
243
|
+
|
|
244
|
+
def _sample(values, n=MAX_SPARK_POINTS):
|
|
245
|
+
if len(values) <= n:
|
|
246
|
+
return values
|
|
247
|
+
step = len(values) / n
|
|
248
|
+
return [values[round(i * step)] for i in range(n)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def render_history(history):
|
|
252
|
+
if len(history) < 2:
|
|
253
|
+
return None
|
|
254
|
+
all_temps = [r.get("temp") for r in history]
|
|
255
|
+
all_winds = [r.get("wspd") for r in history]
|
|
256
|
+
all_inhgs = [r.get("inhg") for r in history]
|
|
257
|
+
|
|
258
|
+
def cell(label, all_vals, spark_style, fmt, unit):
|
|
259
|
+
clean = [v for v in all_vals if v is not None]
|
|
260
|
+
t = Text()
|
|
261
|
+
t.append(f"{label} ", style="dim")
|
|
262
|
+
t.append(sparkline(_sample(all_vals)), style=f"bold {spark_style}")
|
|
263
|
+
if clean:
|
|
264
|
+
t.append(f" {fmt.format(min(clean))}→{fmt.format(max(clean))} {unit}", style=f"dim {spark_style}")
|
|
265
|
+
return t
|
|
266
|
+
|
|
267
|
+
tbl = Table(box=None, show_header=False, padding=(0, 3), expand=False)
|
|
268
|
+
tbl.add_column()
|
|
269
|
+
tbl.add_column()
|
|
270
|
+
tbl.add_column()
|
|
271
|
+
tbl.add_row(
|
|
272
|
+
cell("temp", all_temps, "yellow", "{:.0f}", "°C"),
|
|
273
|
+
cell("wind", all_winds, "cyan", "{:.0f}", "kt"),
|
|
274
|
+
cell("QNH", all_inhgs, "white", "{:.2f}", "inHg"),
|
|
275
|
+
)
|
|
276
|
+
return tbl
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def parse_iso(iso_str):
|
|
280
|
+
try:
|
|
281
|
+
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
|
282
|
+
mins = int((datetime.now(tz=timezone.utc) - dt).total_seconds() / 60)
|
|
283
|
+
return f"{mins}m ago"
|
|
284
|
+
except Exception:
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render_taf(taf):
|
|
289
|
+
if not taf:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
valid_from = fmt_time(taf.get("validTimeFrom", 0))
|
|
293
|
+
valid_to = fmt_time(taf.get("validTimeTo", 0))
|
|
294
|
+
issued = parse_iso(taf.get("issueTime", ""))
|
|
295
|
+
|
|
296
|
+
t = Text()
|
|
297
|
+
t.append(f"valid {valid_from} → {valid_to}", style="dim white")
|
|
298
|
+
t.append(f" issued {issued}\n\n", style="dim cyan")
|
|
299
|
+
|
|
300
|
+
for period in taf.get("fcsts", []):
|
|
301
|
+
change = period.get("fcstChange") or "BASE"
|
|
302
|
+
time_from = fmt_time(period.get("timeFrom", 0))
|
|
303
|
+
time_to = fmt_time(period.get("timeTo", 0))
|
|
304
|
+
|
|
305
|
+
label_style = CHANGE_STYLES.get(change, "bold white")
|
|
306
|
+
|
|
307
|
+
t.append(f"{change:<6} ", style=label_style)
|
|
308
|
+
t.append(f"{time_from}→{time_to} ", style="dim")
|
|
309
|
+
|
|
310
|
+
wdir = period.get("wdir")
|
|
311
|
+
wspd = period.get("wspd") or 0
|
|
312
|
+
wgst = period.get("wgst")
|
|
313
|
+
if wspd == 0 or wspd is None:
|
|
314
|
+
t.append("Calm ", style="cyan")
|
|
315
|
+
else:
|
|
316
|
+
t.append(f"{int(wspd)}", style="bold cyan")
|
|
317
|
+
if wgst:
|
|
318
|
+
t.append(f"G{int(wgst)}", style="bold red")
|
|
319
|
+
t.append("kt", style="cyan")
|
|
320
|
+
if wdir == "VRB":
|
|
321
|
+
t.append(" VRB", style="dim cyan")
|
|
322
|
+
elif wdir is not None:
|
|
323
|
+
card = deg_to_cardinal(wdir)
|
|
324
|
+
t.append(f" {int(wdir)}°{card}", style="dim cyan")
|
|
325
|
+
t.append(" ")
|
|
326
|
+
|
|
327
|
+
vis = period.get("visib")
|
|
328
|
+
if vis is not None:
|
|
329
|
+
t.append(f"{vis}SM ", style="white")
|
|
330
|
+
|
|
331
|
+
clouds = period.get("clouds") or []
|
|
332
|
+
for c in clouds:
|
|
333
|
+
cover = c.get("cover", "")
|
|
334
|
+
base = c.get("base")
|
|
335
|
+
if cover == "SKC" or cover == "CLR":
|
|
336
|
+
t.append("SKC ", style="dim")
|
|
337
|
+
elif base is not None:
|
|
338
|
+
t.append(f"{cover} {base:,}ft ", style="dim white")
|
|
339
|
+
|
|
340
|
+
wx = period.get("wxString")
|
|
341
|
+
if wx:
|
|
342
|
+
t.append(wx, style="bold yellow")
|
|
343
|
+
|
|
344
|
+
t.append("\n")
|
|
345
|
+
|
|
346
|
+
raw = taf.get("rawTAF", "")
|
|
347
|
+
if raw:
|
|
348
|
+
t.append("\n")
|
|
349
|
+
t.append(raw, style="dim white")
|
|
350
|
+
|
|
351
|
+
console.print(Panel(t, title="[dim]TAF[/dim]", border_style="dim"))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def density_alt(temp_c, altim_hpa, elev_m):
|
|
355
|
+
elev_ft = elev_m * 3.28084
|
|
356
|
+
altim_inhg = altim_hpa * 0.02953
|
|
357
|
+
pa = (29.92 - altim_inhg) * 1000 + elev_ft
|
|
358
|
+
isa_temp = 15.0 - (1.98 * pa / 1000)
|
|
359
|
+
return round(pa + 120 * (temp_c - isa_temp))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def parse_remarks(raw):
|
|
363
|
+
if "RMK" not in raw:
|
|
364
|
+
return []
|
|
365
|
+
rmk = raw.split("RMK", 1)[1].strip()
|
|
366
|
+
items = []
|
|
367
|
+
tokens = rmk.split()
|
|
368
|
+
i = 0
|
|
369
|
+
while i < len(tokens):
|
|
370
|
+
tok = tokens[i]
|
|
371
|
+
|
|
372
|
+
m = re.match(r'^SLP(\d{3})$', tok)
|
|
373
|
+
if m:
|
|
374
|
+
v = int(m.group(1))
|
|
375
|
+
hpa = (1000 + v / 10) if v < 500 else (900 + v / 10)
|
|
376
|
+
items.append(("SLP", f"{hpa:.1f} hPa"))
|
|
377
|
+
|
|
378
|
+
elif re.match(r'^T[01]\d{3}[01]\d{3}$', tok):
|
|
379
|
+
ts, ds = tok[1:5], tok[5:]
|
|
380
|
+
tv = int(ts[1:]) / 10 * (-1 if ts[0] == "1" else 1)
|
|
381
|
+
dv = int(ds[1:]) / 10 * (-1 if ds[0] == "1" else 1)
|
|
382
|
+
items.append(("T/Td", f"{tv:.1f}° / {dv:.1f}°"))
|
|
383
|
+
|
|
384
|
+
elif tok == "AO2":
|
|
385
|
+
items.append(("stn", "ASOS / auto"))
|
|
386
|
+
elif tok == "AO1":
|
|
387
|
+
items.append(("stn", "auto (no precip ID)"))
|
|
388
|
+
|
|
389
|
+
elif tok == "PK" and i + 2 < len(tokens) and tokens[i + 1] == "WND":
|
|
390
|
+
m2 = re.match(r'^(\d{3})(\d{2,3})/(\d{4})$', tokens[i + 2])
|
|
391
|
+
if m2:
|
|
392
|
+
items.append(("pk wind", f"{m2.group(2)}kt {m2.group(1)}° :{m2.group(3)[2:]}"))
|
|
393
|
+
i += 2
|
|
394
|
+
|
|
395
|
+
elif tok == "WSHFT" and i + 1 < len(tokens):
|
|
396
|
+
t_str = tokens[i + 1]
|
|
397
|
+
items.append(("wshft", f":{t_str[2:]}"))
|
|
398
|
+
i += 1
|
|
399
|
+
|
|
400
|
+
elif tok == "PRESRR":
|
|
401
|
+
items.append(("pres Δ", "rising rapidly"))
|
|
402
|
+
elif tok == "PRESFR":
|
|
403
|
+
items.append(("pres Δ", "falling rapidly"))
|
|
404
|
+
|
|
405
|
+
elif re.match(r'^P\d{4}$', tok):
|
|
406
|
+
v = int(tok[1:])
|
|
407
|
+
items.append(("precip", "trace" if v == 0 else f"{v / 100:.2f} in"))
|
|
408
|
+
|
|
409
|
+
elif re.match(r'^6\d{4}$', tok):
|
|
410
|
+
v = int(tok[1:])
|
|
411
|
+
items.append(("6h precip", "trace" if v == 0 else f"{v / 100:.2f} in"))
|
|
412
|
+
|
|
413
|
+
elif tok == "TSNO":
|
|
414
|
+
items.append(("TS sensor", "N/A"))
|
|
415
|
+
elif tok == "RVRNO":
|
|
416
|
+
items.append(("RVR", "N/A"))
|
|
417
|
+
elif tok == "FZRANO":
|
|
418
|
+
items.append(("FZRA sensor", "N/A"))
|
|
419
|
+
elif tok == "PWINO":
|
|
420
|
+
items.append(("precip ID", "N/A"))
|
|
421
|
+
|
|
422
|
+
elif tok == "$":
|
|
423
|
+
items.append(("maint", "check needed"))
|
|
424
|
+
|
|
425
|
+
i += 1
|
|
426
|
+
return items
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def render_analysis_panel(rmk_items):
|
|
430
|
+
t = Text()
|
|
431
|
+
for label, val in rmk_items:
|
|
432
|
+
t.append(f"{label:<10}", style="dim")
|
|
433
|
+
t.append(val + "\n", style="white")
|
|
434
|
+
if not rmk_items:
|
|
435
|
+
t.append("no remarks", style="dim")
|
|
436
|
+
return Panel(t, title="[dim]remarks[/dim]", border_style="dim")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def show_station(icao, show_taf=False, raw_only=False):
|
|
440
|
+
m = fetch_metar(icao)
|
|
441
|
+
history = fetch_history(icao)
|
|
442
|
+
|
|
443
|
+
fr = m.get("fltCat") or m.get("flightCategory", "VFR")
|
|
444
|
+
temp = m.get("temp")
|
|
445
|
+
dewp = m.get("dewp")
|
|
446
|
+
wdir = m.get("wdir")
|
|
447
|
+
wspd = m.get("wspd", 0)
|
|
448
|
+
wgst = m.get("wgst")
|
|
449
|
+
visib = m.get("visib", "—")
|
|
450
|
+
altim = m.get("altim")
|
|
451
|
+
elev = m.get("elev")
|
|
452
|
+
clouds = m.get("clouds", [])
|
|
453
|
+
wx = m.get("wxString") or ""
|
|
454
|
+
raw = m.get("rawOb", "")
|
|
455
|
+
name = m.get("name", "")
|
|
456
|
+
obs = m.get("obsTime", 0)
|
|
457
|
+
|
|
458
|
+
if raw_only:
|
|
459
|
+
console.print(raw)
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
fr_color, fr_style = FR_STYLES.get(fr, ("white", "bold white"))
|
|
463
|
+
|
|
464
|
+
# ── Header ──────────────────────────────────────────────────────────
|
|
465
|
+
hdr = Text()
|
|
466
|
+
hdr.append(f"METAR {icao}", style="bold white")
|
|
467
|
+
if name:
|
|
468
|
+
hdr.append(f" · {name}", style="dim white")
|
|
469
|
+
hdr.append(f" {age_str(obs)}", style="dim cyan")
|
|
470
|
+
console.print(hdr)
|
|
471
|
+
console.rule(style="dim white")
|
|
472
|
+
|
|
473
|
+
# ── Stat cards ───────────────────────────────────────────────────────
|
|
474
|
+
stats = Table(box=None, show_header=False, padding=(0, 2), expand=False)
|
|
475
|
+
for _ in range(6):
|
|
476
|
+
stats.add_column(justify="center")
|
|
477
|
+
|
|
478
|
+
fr_cell = Text(f" {fr} ", style=fr_style)
|
|
479
|
+
|
|
480
|
+
temp_cell = Text()
|
|
481
|
+
if temp is not None:
|
|
482
|
+
temp_cell.append(f"{int(temp)}°C", style="bold yellow")
|
|
483
|
+
if dewp is not None:
|
|
484
|
+
temp_cell.append(f" / {int(dewp)}°", style="dim yellow")
|
|
485
|
+
|
|
486
|
+
wind_val = Text()
|
|
487
|
+
wind_sub = Text("")
|
|
488
|
+
if not wspd or wspd == 0:
|
|
489
|
+
wind_val.append("Calm", style="bold cyan")
|
|
490
|
+
else:
|
|
491
|
+
wind_val.append(f"{int(wspd)}", style="bold cyan")
|
|
492
|
+
if wgst:
|
|
493
|
+
wind_val.append(f"G{int(wgst)}", style="bold red")
|
|
494
|
+
wind_val.append("kt", style="cyan")
|
|
495
|
+
wind_val.append(f" {int(wdir)}°", style="dim cyan")
|
|
496
|
+
wind_sub = Text(deg_to_cardinal(wdir), style="dim")
|
|
497
|
+
|
|
498
|
+
vis_cell = Text(f"{visib} mi", style="bold white")
|
|
499
|
+
ceil_cell = Text(get_ceiling(clouds), style="bold white")
|
|
500
|
+
qnh_cell = Text(f"{hpa_to_inhg(altim)} inHg" if altim else "—", style="bold white")
|
|
501
|
+
|
|
502
|
+
stats.add_row(fr_cell, temp_cell, wind_val, vis_cell, ceil_cell, qnh_cell)
|
|
503
|
+
stats.add_row(
|
|
504
|
+
Text(""),
|
|
505
|
+
Text(decode_wx(wx) or "clear", style="dim"),
|
|
506
|
+
wind_sub if wspd else Text(""),
|
|
507
|
+
Text("Visibility", style="dim"),
|
|
508
|
+
Text("Ceiling", style="dim"),
|
|
509
|
+
Text("QNH", style="dim"),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
console.print(stats)
|
|
513
|
+
console.rule(style="dim white")
|
|
514
|
+
|
|
515
|
+
# ── Wind rose + cloud layers side by side ────────────────────────────
|
|
516
|
+
rose_text = render_rose(wdir, wspd, fr_color)
|
|
517
|
+
if temp is not None and altim and elev is not None:
|
|
518
|
+
da = density_alt(temp, altim, elev)
|
|
519
|
+
da_style = "bold green" if da < 3000 else ("bold yellow" if da < 5000 else "bold red")
|
|
520
|
+
elev_ft = round(elev * 3.28084)
|
|
521
|
+
rose_text.append("\n──────\n", style="dim")
|
|
522
|
+
rose_text.append("elev ", style="dim")
|
|
523
|
+
rose_text.append(f"{elev_ft:,}ft\n", style="white")
|
|
524
|
+
rose_text.append("DA ", style="dim")
|
|
525
|
+
rose_text.append(f"{da:,}ft", style=da_style)
|
|
526
|
+
rose_panel = Panel(rose_text, title="[dim]wind[/dim]", width=18, border_style="dim")
|
|
527
|
+
|
|
528
|
+
clouds_text = Text()
|
|
529
|
+
altitudes = [20000, 12000, 6000, 3000, 1500, 500, 0]
|
|
530
|
+
alt_labels = {20000: "20k", 12000: "12k", 6000: " 6k",
|
|
531
|
+
3000: " 3k", 1500: "1.5", 500: "500", 0: "sfc"}
|
|
532
|
+
cloud_rows: dict = {}
|
|
533
|
+
for c in (clouds or []):
|
|
534
|
+
base = c.get("base")
|
|
535
|
+
if base is None:
|
|
536
|
+
continue
|
|
537
|
+
nearest = min(altitudes, key=lambda a: abs(a - base))
|
|
538
|
+
cloud_rows.setdefault(nearest, []).append(c["cover"])
|
|
539
|
+
for alt in altitudes:
|
|
540
|
+
clouds_text.append(f"{alt_labels[alt]} │ ", style="dim")
|
|
541
|
+
for cover in cloud_rows.get(alt, []):
|
|
542
|
+
clouds_text.append(f"☁ {cover}", style="bold white")
|
|
543
|
+
clouds_text.append("\n")
|
|
544
|
+
clouds_text.append(" └" + "─" * 12, style="dim")
|
|
545
|
+
|
|
546
|
+
clouds_panel = Panel(clouds_text, title="[dim]clouds[/dim]", width=22, border_style="dim")
|
|
547
|
+
|
|
548
|
+
rmk_items = parse_remarks(raw)
|
|
549
|
+
analysis_panel = render_analysis_panel(rmk_items)
|
|
550
|
+
console.print(Columns([rose_panel, clouds_panel, analysis_panel]))
|
|
551
|
+
console.rule(style="dim white")
|
|
552
|
+
|
|
553
|
+
# ── Sparkline history ────────────────────────────────────────────────
|
|
554
|
+
hist_text = render_history(history)
|
|
555
|
+
if hist_text:
|
|
556
|
+
console.print(Panel(hist_text, title="[dim]history (6h)[/dim]", border_style="dim"))
|
|
557
|
+
console.rule(style="dim white")
|
|
558
|
+
|
|
559
|
+
# ── Raw METAR ────────────────────────────────────────────────────────
|
|
560
|
+
console.print(Panel(Text(raw, style="dim white"), title="[dim]raw[/dim]", border_style="dim"))
|
|
561
|
+
|
|
562
|
+
# ── TAF ──────────────────────────────────────────────────────────────
|
|
563
|
+
if show_taf:
|
|
564
|
+
console.rule(style="dim white")
|
|
565
|
+
taf = fetch_taf(icao)
|
|
566
|
+
render_taf(taf)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def getch():
|
|
570
|
+
fd = sys.stdin.fileno()
|
|
571
|
+
old = termios.tcgetattr(fd)
|
|
572
|
+
try:
|
|
573
|
+
tty.setraw(fd)
|
|
574
|
+
return sys.stdin.read(1)
|
|
575
|
+
finally:
|
|
576
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _icao_dialog(title, subtitle, label, hint, border_color="#00aaff", error=None):
|
|
580
|
+
from prompt_toolkit import Application
|
|
581
|
+
from prompt_toolkit.buffer import Buffer
|
|
582
|
+
from prompt_toolkit.formatted_text import HTML
|
|
583
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
584
|
+
from prompt_toolkit.layout import Layout
|
|
585
|
+
from prompt_toolkit.layout.containers import HSplit, VSplit, Window, WindowAlign
|
|
586
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
587
|
+
from prompt_toolkit.widgets import Frame
|
|
588
|
+
from prompt_toolkit.styles import Style
|
|
589
|
+
|
|
590
|
+
result = [None]
|
|
591
|
+
buf = Buffer()
|
|
592
|
+
|
|
593
|
+
kb = KeyBindings()
|
|
594
|
+
|
|
595
|
+
@kb.add("enter")
|
|
596
|
+
def _(event):
|
|
597
|
+
val = buf.text.strip().upper()
|
|
598
|
+
if val:
|
|
599
|
+
result[0] = val
|
|
600
|
+
event.app.exit()
|
|
601
|
+
|
|
602
|
+
@kb.add("c-c")
|
|
603
|
+
@kb.add("c-d")
|
|
604
|
+
def _(event):
|
|
605
|
+
event.app.exit()
|
|
606
|
+
|
|
607
|
+
def row(content, style=""):
|
|
608
|
+
return Window(
|
|
609
|
+
FormattedTextControl(content),
|
|
610
|
+
height=1,
|
|
611
|
+
align=WindowAlign.CENTER,
|
|
612
|
+
style=style,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
body = [
|
|
616
|
+
Window(height=1),
|
|
617
|
+
row(HTML(f"<b>{title}</b>")),
|
|
618
|
+
row(subtitle, style="fg:#888888"),
|
|
619
|
+
Window(height=1),
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
if error:
|
|
623
|
+
body.append(row(HTML(f"<ansired>✗ {error}</ansired>")))
|
|
624
|
+
body.append(Window(height=1))
|
|
625
|
+
|
|
626
|
+
body += [
|
|
627
|
+
row(label, style="fg:#aaaaaa"),
|
|
628
|
+
Window(height=1),
|
|
629
|
+
Frame(
|
|
630
|
+
Window(content=BufferControl(buffer=buf), height=1),
|
|
631
|
+
style="class:input-frame",
|
|
632
|
+
),
|
|
633
|
+
Window(height=1),
|
|
634
|
+
row(hint, style="fg:#555555"),
|
|
635
|
+
Window(height=1),
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
dialog = Frame(HSplit(body), style="class:outer-frame", width=36)
|
|
639
|
+
root = HSplit([Window(), VSplit([Window(), dialog, Window()]), Window()])
|
|
640
|
+
|
|
641
|
+
style = Style.from_dict({
|
|
642
|
+
"outer-frame frame.border": f"fg:{border_color}",
|
|
643
|
+
"outer-frame frame.label": f"bold fg:{border_color}",
|
|
644
|
+
"input-frame frame.border": "fg:#334455",
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
app = Application(
|
|
648
|
+
layout=Layout(root, focused_element=buf),
|
|
649
|
+
key_bindings=kb,
|
|
650
|
+
style=style,
|
|
651
|
+
full_screen=True,
|
|
652
|
+
mouse_support=False,
|
|
653
|
+
)
|
|
654
|
+
app.run()
|
|
655
|
+
return result[0]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def search_prompt(error=None):
|
|
659
|
+
return _icao_dialog(
|
|
660
|
+
title="✈ metar-cli",
|
|
661
|
+
subtitle="METAR + TAF · terminal dashboard",
|
|
662
|
+
label="input station",
|
|
663
|
+
hint="enter ↵ to search ctrl+c to quit",
|
|
664
|
+
error=error,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def config_prompt():
|
|
669
|
+
current = get_default_icao()
|
|
670
|
+
return _icao_dialog(
|
|
671
|
+
title="⚙ set default station",
|
|
672
|
+
subtitle=f"current default: {current}",
|
|
673
|
+
label="new default ICAO",
|
|
674
|
+
hint="enter ↵ to save ctrl+c to cancel",
|
|
675
|
+
border_color="#ffaa00",
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def interactive_mode():
|
|
680
|
+
icao = None
|
|
681
|
+
show_taf = False
|
|
682
|
+
raw_mode = False
|
|
683
|
+
error = None
|
|
684
|
+
|
|
685
|
+
while True:
|
|
686
|
+
# ── Search screen ────────────────────────────────────────────────
|
|
687
|
+
if icao is None:
|
|
688
|
+
icao = search_prompt(error=error)
|
|
689
|
+
error = None
|
|
690
|
+
if icao is None:
|
|
691
|
+
console.clear()
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
# ── Display screen ───────────────────────────────────────────────
|
|
695
|
+
console.clear()
|
|
696
|
+
try:
|
|
697
|
+
show_station(icao, show_taf=show_taf, raw_only=raw_mode)
|
|
698
|
+
except (ValueError, requests.RequestException) as e:
|
|
699
|
+
error = str(e)
|
|
700
|
+
icao = None
|
|
701
|
+
continue
|
|
702
|
+
|
|
703
|
+
# ── Key bar ──────────────────────────────────────────────────────
|
|
704
|
+
console.print()
|
|
705
|
+
console.rule(style="dim")
|
|
706
|
+
|
|
707
|
+
bar = Text(justify="center")
|
|
708
|
+
bar.append("q", style="bold cyan"); bar.append(" quit", style="dim")
|
|
709
|
+
bar.append(" t", style="bold cyan" if not show_taf else "bold green")
|
|
710
|
+
bar.append(" taf" + (" ✓" if show_taf else ""), style="dim" if not show_taf else "green")
|
|
711
|
+
bar.append(" r", style="bold cyan" if not raw_mode else "bold green")
|
|
712
|
+
bar.append(" raw" + (" ✓" if raw_mode else ""), style="dim" if not raw_mode else "green")
|
|
713
|
+
bar.append(" s", style="bold cyan"); bar.append(" search", style="dim")
|
|
714
|
+
bar.append(" u", style="bold cyan"); bar.append(" refresh", style="dim")
|
|
715
|
+
bar.append(" c", style="bold cyan"); bar.append(" config", style="dim")
|
|
716
|
+
console.print(bar)
|
|
717
|
+
|
|
718
|
+
key = getch()
|
|
719
|
+
if key in ("q", "Q", "\x03"): # q or Ctrl+C
|
|
720
|
+
console.clear()
|
|
721
|
+
return
|
|
722
|
+
elif key in ("t", "T"):
|
|
723
|
+
show_taf = not show_taf
|
|
724
|
+
elif key in ("r", "R"):
|
|
725
|
+
raw_mode = not raw_mode
|
|
726
|
+
elif key in ("s", "S"):
|
|
727
|
+
icao = None
|
|
728
|
+
show_taf = False
|
|
729
|
+
raw_mode = False
|
|
730
|
+
elif key in ("c", "C"):
|
|
731
|
+
new_default = config_prompt()
|
|
732
|
+
if new_default:
|
|
733
|
+
set_default_icao(new_default)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def main():
|
|
737
|
+
parser = argparse.ArgumentParser(
|
|
738
|
+
prog="metar",
|
|
739
|
+
description="Aviation weather dashboard — METAR + TAF",
|
|
740
|
+
)
|
|
741
|
+
parser.add_argument(
|
|
742
|
+
"icao", nargs="*", default=[],
|
|
743
|
+
metavar="ICAO", help="One or more ICAO station codes",
|
|
744
|
+
)
|
|
745
|
+
parser.add_argument("--taf", action="store_true", help="Include TAF forecast block")
|
|
746
|
+
parser.add_argument("--raw", action="store_true", help="Print raw METAR string only")
|
|
747
|
+
parser.add_argument("-i", "--interactive", action="store_true", help="Interactive mode")
|
|
748
|
+
parser.add_argument("--set-default", metavar="ICAO", help="Save a default station to ~/.config/metar/config")
|
|
749
|
+
args = parser.parse_args()
|
|
750
|
+
|
|
751
|
+
if args.set_default:
|
|
752
|
+
set_default_icao(args.set_default)
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
if args.interactive:
|
|
756
|
+
interactive_mode()
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
stations = [s.upper() for s in args.icao] or [get_default_icao()]
|
|
760
|
+
for i, icao in enumerate(stations):
|
|
761
|
+
if i > 0:
|
|
762
|
+
console.print()
|
|
763
|
+
show_station(icao, show_taf=args.taf, raw_only=args.raw)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
if __name__ == "__main__":
|
|
767
|
+
main()
|
|
768
|
+
# HOWDY! thanks for reading the code ;) - Alex gc / soup - weekend project finished 13 jun 2026
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metar-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Terminal METAR + TAF dashboard for pilots and aviation nerds
|
|
5
|
+
Author: Alex gc
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/alexgc96/metar-cli
|
|
8
|
+
Classifier: Operating System :: MacOS
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: rich
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: prompt_toolkit
|
|
18
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
metar.py,sha256=omhi0jJn1FPL6R53ZOMLnlzXNytpryKh0kzkRwnhrAk,25362
|
|
2
|
+
metar_cli-0.3.0.dist-info/licenses/LICENSE,sha256=MoMtSQu7lwZ0P_RzCXGg_P7AXbE-DguBG1O8uh_1knA,1064
|
|
3
|
+
metar_cli-0.3.0.dist-info/METADATA,sha256=ersoCujSEC5wKPGgBRUvXz5-j_7Epr6IWz-VajaRd8Q,576
|
|
4
|
+
metar_cli-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
metar_cli-0.3.0.dist-info/entry_points.txt,sha256=SDuhBrXetJk21fClPmJuOHGkMT7F3nMQvs3PvNvpDZw,37
|
|
6
|
+
metar_cli-0.3.0.dist-info/top_level.txt,sha256=OdcHEZjwJmgAVrqww48n4wgK2Ht6XcWFsf4ssKnNpOs,6
|
|
7
|
+
metar_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex gc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metar
|