metar-cli 0.3.0__tar.gz
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_cli-0.3.0/LICENSE +21 -0
- metar_cli-0.3.0/PKG-INFO +18 -0
- metar_cli-0.3.0/README.md +198 -0
- metar_cli-0.3.0/metar.py +768 -0
- metar_cli-0.3.0/metar_cli.egg-info/PKG-INFO +18 -0
- metar_cli-0.3.0/metar_cli.egg-info/SOURCES.txt +10 -0
- metar_cli-0.3.0/metar_cli.egg-info/dependency_links.txt +1 -0
- metar_cli-0.3.0/metar_cli.egg-info/entry_points.txt +2 -0
- metar_cli-0.3.0/metar_cli.egg-info/requires.txt +3 -0
- metar_cli-0.3.0/metar_cli.egg-info/top_level.txt +1 -0
- metar_cli-0.3.0/pyproject.toml +34 -0
- metar_cli-0.3.0/setup.cfg +4 -0
metar_cli-0.3.0/LICENSE
ADDED
|
@@ -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.
|
metar_cli-0.3.0/PKG-INFO
ADDED
|
@@ -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,198 @@
|
|
|
1
|
+
# metar-cli
|
|
2
|
+
|
|
3
|
+
Terminal METAR + TAF dashboard for pilots and aviation nerds. Live weather data rendered straight in your terminal — think wttr.in but aviation-focused.
|
|
4
|
+
|
|
5
|
+
Default station: **MMML** (General Rodolfo Sánchez Taboada Intl, Mexicali, BC, MX)
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
METAR MMML · Mexicali Intl, BC, MX 12m ago
|
|
9
|
+
────────────────────────────────────────────────
|
|
10
|
+
VFR 30°C / 14° 3kt 60° 10+ mi None 29.71 inHg
|
|
11
|
+
clear NE Visibility Ceiling QNH
|
|
12
|
+
────────────────────────────────────────────────
|
|
13
|
+
╭─── wind ───╮ ╭──── clouds ────╮ ╭──── remarks ────╮
|
|
14
|
+
│ ↖ ↑ ↗ │ │ 20k │ │ │ stn ASOS/auto │
|
|
15
|
+
│ ← · → │ │ 12k │ │ │ SLP 1008.1 hPa│
|
|
16
|
+
│ ↙ ↓ ↘ │ │ 6k │ │ │ T/Td 30.0/14.4 │
|
|
17
|
+
│ ────── │ │ 3k │ │ ╰─────────────────╯
|
|
18
|
+
│ elev 69ft │ │ 1.5 │ │
|
|
19
|
+
│ DA 2,141ft│ │ 500 │ │
|
|
20
|
+
╰────────────╯ │ sfc │ │
|
|
21
|
+
│ └──────── │
|
|
22
|
+
╰────────────────╯
|
|
23
|
+
────────────────────────────────────────────────
|
|
24
|
+
temp ▁▂▄▆█▅▃▂▁▂▃▄ 30→37 °C
|
|
25
|
+
wind ▃▂▁▁▂▃▄▃▂▁▂▃ 0→10 kt
|
|
26
|
+
QNH ▅▄▃▄▅▆▇▆▅▄▅▆ 29.66→29.72 inHg
|
|
27
|
+
────────────────────────────────────────────────
|
|
28
|
+
METAR MMML 130647Z 06003KT 10SM SKC 30/14 A2971
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
-----
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
The recommended way is [pipx](https://pipx.pypa.io), which installs `metar` as a global command without polluting your system Python:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install metar-cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That’s it. `metar` is now available from anywhere.
|
|
42
|
+
|
|
43
|
+
**Don’t have pipx?**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# macOS (MacPorts)
|
|
47
|
+
sudo port install pipx
|
|
48
|
+
|
|
49
|
+
# macOS (Homebrew)
|
|
50
|
+
brew install pipx
|
|
51
|
+
|
|
52
|
+
# Linux / other
|
|
53
|
+
pip install --user pipx
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**For development / contributing:**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/alexgc96/metar-cli.git
|
|
60
|
+
cd metar-cli
|
|
61
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
62
|
+
pip install -e .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
-----
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
metar [-h] [--taf] [--raw] [-i] [--set-default ICAO] [ICAO ...]
|
|
71
|
+
|
|
72
|
+
positional arguments:
|
|
73
|
+
ICAO One or more ICAO station codes
|
|
74
|
+
|
|
75
|
+
options:
|
|
76
|
+
-h, --help show this help message and exit
|
|
77
|
+
--taf Include TAF forecast block
|
|
78
|
+
--raw Print raw METAR string only
|
|
79
|
+
-i, --interactive Interactive mode
|
|
80
|
+
--set-default ICAO Save a default station to ~/.config/metar/config
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
-----
|
|
84
|
+
|
|
85
|
+
## Interactive mode (`-i`)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
metar -i
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Full-screen ICAO search box. Once a station is loaded:
|
|
92
|
+
|
|
93
|
+
|Key|Action |
|
|
94
|
+
|---|-----------------------|
|
|
95
|
+
|`t`|Toggle TAF forecast |
|
|
96
|
+
|`r`|Toggle raw METAR string|
|
|
97
|
+
|`u`|Refresh / re-fetch data|
|
|
98
|
+
|`s`|New station search |
|
|
99
|
+
|`c`|Set default station |
|
|
100
|
+
|`q`|Quit |
|
|
101
|
+
|
|
102
|
+
-----
|
|
103
|
+
|
|
104
|
+
## Data sources
|
|
105
|
+
|
|
106
|
+
### METAR + TAF — aviationweather.gov
|
|
107
|
+
|
|
108
|
+
All primary weather data comes from the **Aviation Weather Center (AWC)** public API operated by NOAA/NWS. No API key required.
|
|
109
|
+
|
|
110
|
+
|Data |Endpoint |
|
|
111
|
+
|---------------|-----------------------------------------------------------------|
|
|
112
|
+
|METAR (current)|`https://aviationweather.gov/api/data/metar?ids=MMML&format=json`|
|
|
113
|
+
|TAF (forecast) |`https://aviationweather.gov/api/data/taf?ids=MMML&format=json` |
|
|
114
|
+
|
|
115
|
+
**Fields used from METAR JSON:**
|
|
116
|
+
|
|
117
|
+
- `temp`, `dewp` — temperature and dew point (°C)
|
|
118
|
+
- `wdir`, `wspd`, `wgst` — wind direction (°), speed and gust (kt)
|
|
119
|
+
- `visib` — visibility (statute miles)
|
|
120
|
+
- `altim` — altimeter setting (hPa, converted to inHg for display)
|
|
121
|
+
- `clouds` — array of `{cover, base}` objects (e.g. `BKN`, `OVC` at feet MSL)
|
|
122
|
+
- `fltCat` — computed flight category (VFR / MVFR / IFR / LIFR)
|
|
123
|
+
- `wxString` — present weather string (e.g. `TSRA`, `-RA`, `BR`)
|
|
124
|
+
- `rawOb` — full raw METAR string including remarks
|
|
125
|
+
- `elev` — station elevation (metres)
|
|
126
|
+
- `lat`, `lon` — station coordinates
|
|
127
|
+
- `obsTime` — observation Unix timestamp
|
|
128
|
+
|
|
129
|
+
**Fields used from TAF JSON:**
|
|
130
|
+
|
|
131
|
+
- `validTimeFrom`, `validTimeTo` — forecast valid period (Unix timestamps)
|
|
132
|
+
- `issueTime` — issue time (ISO 8601 string)
|
|
133
|
+
- `rawTAF` — full raw TAF string
|
|
134
|
+
- `fcsts` — array of forecast periods, each with `timeFrom`, `timeTo`, `fcstChange`, `wdir`, `wspd`, `wgst`, `visib`, `clouds`, `wxString`
|
|
135
|
+
|
|
136
|
+
### Historical observations — Iowa State Mesonet (ASOS)
|
|
137
|
+
|
|
138
|
+
The 6-hour sparkline history uses the **Iowa State University Environmental Mesonet** ASOS archive. Free, no auth required.
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py
|
|
142
|
+
?station=MMML
|
|
143
|
+
&data=tmpf,drct,sknt,alti
|
|
144
|
+
&format=onlycomma
|
|
145
|
+
&missing=null
|
|
146
|
+
&tz=UTC
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Fields used:** `tmpf` (temp °F, converted to °C), `sknt` (wind speed kt), `alti` (altimeter inHg). Up to 76 observations per 6h window are sampled down to 12 points for the sparkline; the full dataset is used for accurate min/max ranges.
|
|
150
|
+
|
|
151
|
+
### Computed values
|
|
152
|
+
|
|
153
|
+
- **Density altitude** — calculated from OAT, altimeter setting, and station elevation using the standard ISA lapse rate formula. No external data required.
|
|
154
|
+
- **wx string decode** — parsed locally from the `wxString` field using ICAO intensity/descriptor/phenomenon codes. No external data required.
|
|
155
|
+
- **Remarks decode** — parsed locally from the `rawOb` RMK section (SLP, precise T/Td, station type, peak wind, pressure tendency, precip, maintenance flag).
|
|
156
|
+
|
|
157
|
+
-----
|
|
158
|
+
|
|
159
|
+
## Configuration
|
|
160
|
+
|
|
161
|
+
Set a persistent default station:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
metar --set-default KJFK
|
|
165
|
+
# saves to ~/.config/metar/config
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Or use an environment variable (takes priority over the config file):
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
export METAR_ICAO=MMML
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
-----
|
|
175
|
+
|
|
176
|
+
## Safety disclaimer
|
|
177
|
+
|
|
178
|
+
> **metar-cli is a convenience tool for curiosity and preflight awareness — not a substitute for an official weather briefing.**
|
|
179
|
+
>
|
|
180
|
+
> Real and virtual pilots alike should always consult their country’s aviation authority and any flight planning services available to them before flight. In the US this means a standard weather briefing via 1800wxbrief.com or ForeFlight. In Mexico, consult SENEAM and your applicable NOTAMs. In other countries, use whatever official sources your CAA provides.
|
|
181
|
+
>
|
|
182
|
+
> A single METAR is a snapshot in time at one point on the ground. It does not capture en-route conditions, winds aloft, SIGMETs, AIRMETs, TFRs, or anything happening above the field. Always get the full picture.
|
|
183
|
+
|
|
184
|
+
-----
|
|
185
|
+
|
|
186
|
+
## Stack
|
|
187
|
+
|
|
188
|
+
- **Python 3.10+**
|
|
189
|
+
- [`rich`](https://github.com/Textualize/rich) — terminal layout, panels, color, sparklines
|
|
190
|
+
- [`requests`](https://requests.readthedocs.io/) — HTTP
|
|
191
|
+
- [`prompt_toolkit`](https://python-prompt-toolkit.readthedocs.io/) — interactive mode UI
|
|
192
|
+
|
|
193
|
+
-----
|
|
194
|
+
|
|
195
|
+
## Inspiration
|
|
196
|
+
|
|
197
|
+
- [wttr.in](https://wttr.in) — terminal weather density as a design goal
|
|
198
|
+
- [metar-taf.com](https://metar-taf.com) — MMML dashboard, June 2026
|
metar_cli-0.3.0/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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metar
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metar-cli"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Alex gc"}
|
|
10
|
+
]
|
|
11
|
+
description = "Terminal METAR + TAF dashboard for pilots and aviation nerds"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"rich",
|
|
15
|
+
"requests",
|
|
16
|
+
"prompt_toolkit",
|
|
17
|
+
]
|
|
18
|
+
license = {text = "MIT"}
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/alexgc96/metar-cli"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
metar = "metar:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
py-modules = ["metar"]
|