air-status 0.1.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.
- air_status-0.1.0/LICENSE +21 -0
- air_status-0.1.0/PKG-INFO +121 -0
- air_status-0.1.0/README.md +95 -0
- air_status-0.1.0/pyproject.toml +49 -0
- air_status-0.1.0/setup.cfg +4 -0
- air_status-0.1.0/src/air_status/__init__.py +3 -0
- air_status-0.1.0/src/air_status/__main__.py +4 -0
- air_status-0.1.0/src/air_status/braille.py +70 -0
- air_status-0.1.0/src/air_status/cli.py +69 -0
- air_status-0.1.0/src/air_status/flight_map.py +206 -0
- air_status-0.1.0/src/air_status/flightlog.py +134 -0
- air_status-0.1.0/src/air_status/geo.py +40 -0
- air_status-0.1.0/src/air_status/models.py +54 -0
- air_status-0.1.0/src/air_status/providers/__init__.py +41 -0
- air_status-0.1.0/src/air_status/providers/aircanada.py +9 -0
- air_status-0.1.0/src/air_status/providers/american.py +179 -0
- air_status-0.1.0/src/air_status/providers/base.py +38 -0
- air_status-0.1.0/src/air_status/providers/gogo.py +111 -0
- air_status-0.1.0/src/air_status/tui.py +184 -0
- air_status-0.1.0/src/air_status.egg-info/PKG-INFO +121 -0
- air_status-0.1.0/src/air_status.egg-info/SOURCES.txt +27 -0
- air_status-0.1.0/src/air_status.egg-info/dependency_links.txt +1 -0
- air_status-0.1.0/src/air_status.egg-info/entry_points.txt +2 -0
- air_status-0.1.0/src/air_status.egg-info/requires.txt +7 -0
- air_status-0.1.0/src/air_status.egg-info/top_level.txt +1 -0
- air_status-0.1.0/tests/test_flight_map.py +37 -0
- air_status-0.1.0/tests/test_flightlog.py +71 -0
- air_status-0.1.0/tests/test_geo_and_map.py +58 -0
- air_status-0.1.0/tests/test_providers.py +215 -0
air_status-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max
|
|
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,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: air-status
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Live in-flight status from airline Wi-Fi portals, with a terminal map and flight history
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/max2697/air-status
|
|
7
|
+
Project-URL: Issues, https://github.com/max2697/air-status/issues
|
|
8
|
+
Keywords: flight,in-flight wifi,terminal,tui,map,aviation
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: rich>=13
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
23
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
24
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# air-status
|
|
28
|
+
|
|
29
|
+
Live flight status in the terminal while you're on board, read from the airline's
|
|
30
|
+
in-flight Wi-Fi portal: altitude, speed, ETA, a Braille map of the route, and a
|
|
31
|
+
saved history of every flight.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
<sub>Made-up flight. Regenerate with `.venv/bin/python scripts/screenshot.py`.</sub>
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pipx install air-status # or: pip install air-status
|
|
41
|
+
air-status
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
From a checkout, for development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python3 -m venv .venv
|
|
48
|
+
.venv/bin/pip install -e '.[dev]'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Use
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
.venv/bin/air-status # live view, airline detected from the Wi-Fi, saves history
|
|
55
|
+
.venv/bin/air-status live -p aircanada --every 10
|
|
56
|
+
.venv/bin/air-status live -p american # American Airlines (Viasat aircraft)
|
|
57
|
+
.venv/bin/air-status history # list saved flights
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run it in a real terminal for the live full-screen view. In a plain output window
|
|
61
|
+
(e.g. PyCharm's Run tab) it prints one frame per update instead.
|
|
62
|
+
|
|
63
|
+
Files go in the current folder (change with `--data-dir`):
|
|
64
|
+
|
|
65
|
+
- `history/<provider>/` — one file per flight, in that provider's own format.
|
|
66
|
+
Air Canada (Gogo) stores the raw portal data as change-only JSON Lines: the
|
|
67
|
+
first line is the full reading, later lines hold only the fields that changed.
|
|
68
|
+
Your device's IP/MAC address is never saved.
|
|
69
|
+
American Airlines stores the Viasat flight feed plus the route's airport
|
|
70
|
+
locations (looked up once from the portal) in the same change-only format.
|
|
71
|
+
Its feed has no vertical speed, so climb/descent is worked out from the
|
|
72
|
+
altitude change over the last minute (and saved too).
|
|
73
|
+
- `mapdata/` — map outlines from [Natural Earth](https://www.naturalearthdata.com/),
|
|
74
|
+
downloaded on first run.
|
|
75
|
+
|
|
76
|
+
The map draws the flown path from history (solid red). Where there is no history,
|
|
77
|
+
such as before recording started or during gaps over 60 s, it draws an estimated
|
|
78
|
+
shortest-path line between the known points (dashed red).
|
|
79
|
+
|
|
80
|
+
## Adding an airline
|
|
81
|
+
|
|
82
|
+
Providers live in `src/air_status/providers/`. A provider:
|
|
83
|
+
|
|
84
|
+
- `fetch()` — gets a raw reading from its portal,
|
|
85
|
+
- `parse(reading)` — turns it into the shared `FlightStatus` (`models.py`),
|
|
86
|
+
- `history(directory)` — returns its `FlightHistory` (`flightlog.py`), which
|
|
87
|
+
decides how readings are written and read back. Reuse `ChangeLog` for
|
|
88
|
+
change-only JSON Lines, or implement your own format.
|
|
89
|
+
|
|
90
|
+
Auto-detect (the default, `-p auto`) asks every provider's portal at once via
|
|
91
|
+
`detect()` and uses the first that returns flight data. The default `detect()`
|
|
92
|
+
is "fetch and parse succeed"; override it when portals are shared (Gogo
|
|
93
|
+
providers also check `airline_code`).
|
|
94
|
+
|
|
95
|
+
Same Wi-Fi system as an existing provider (e.g. another Gogo/Intelsat airline):
|
|
96
|
+
subclass it and set `id`, `name` and `airline_code`, like `aircanada.py`. Then add an instance
|
|
97
|
+
to `PROVIDERS` in `providers/__init__.py`.
|
|
98
|
+
|
|
99
|
+
## Tests and lint
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
.venv/bin/pytest
|
|
103
|
+
.venv/bin/ruff check .
|
|
104
|
+
.venv/bin/ruff format .
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
GitHub Actions (`.github/workflows/ci.yml`) runs the same lint plus tests on
|
|
108
|
+
Python 3.9–3.14 for every push to `main` and every pull request.
|
|
109
|
+
|
|
110
|
+
## Releasing
|
|
111
|
+
|
|
112
|
+
1. Bump `__version__` in `src/air_status/__init__.py` and commit.
|
|
113
|
+
2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
|
|
114
|
+
|
|
115
|
+
`.github/workflows/release.yml` checks the tag matches the version, runs lint
|
|
116
|
+
and tests, builds the package, publishes it to PyPI (trusted publishing, no
|
|
117
|
+
token) and creates a GitHub release with the built files.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# air-status
|
|
2
|
+
|
|
3
|
+
Live flight status in the terminal while you're on board, read from the airline's
|
|
4
|
+
in-flight Wi-Fi portal: altitude, speed, ETA, a Braille map of the route, and a
|
|
5
|
+
saved history of every flight.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
<sub>Made-up flight. Regenerate with `.venv/bin/python scripts/screenshot.py`.</sub>
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pipx install air-status # or: pip install air-status
|
|
15
|
+
air-status
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
From a checkout, for development:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python3 -m venv .venv
|
|
22
|
+
.venv/bin/pip install -e '.[dev]'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
.venv/bin/air-status # live view, airline detected from the Wi-Fi, saves history
|
|
29
|
+
.venv/bin/air-status live -p aircanada --every 10
|
|
30
|
+
.venv/bin/air-status live -p american # American Airlines (Viasat aircraft)
|
|
31
|
+
.venv/bin/air-status history # list saved flights
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Run it in a real terminal for the live full-screen view. In a plain output window
|
|
35
|
+
(e.g. PyCharm's Run tab) it prints one frame per update instead.
|
|
36
|
+
|
|
37
|
+
Files go in the current folder (change with `--data-dir`):
|
|
38
|
+
|
|
39
|
+
- `history/<provider>/` — one file per flight, in that provider's own format.
|
|
40
|
+
Air Canada (Gogo) stores the raw portal data as change-only JSON Lines: the
|
|
41
|
+
first line is the full reading, later lines hold only the fields that changed.
|
|
42
|
+
Your device's IP/MAC address is never saved.
|
|
43
|
+
American Airlines stores the Viasat flight feed plus the route's airport
|
|
44
|
+
locations (looked up once from the portal) in the same change-only format.
|
|
45
|
+
Its feed has no vertical speed, so climb/descent is worked out from the
|
|
46
|
+
altitude change over the last minute (and saved too).
|
|
47
|
+
- `mapdata/` — map outlines from [Natural Earth](https://www.naturalearthdata.com/),
|
|
48
|
+
downloaded on first run.
|
|
49
|
+
|
|
50
|
+
The map draws the flown path from history (solid red). Where there is no history,
|
|
51
|
+
such as before recording started or during gaps over 60 s, it draws an estimated
|
|
52
|
+
shortest-path line between the known points (dashed red).
|
|
53
|
+
|
|
54
|
+
## Adding an airline
|
|
55
|
+
|
|
56
|
+
Providers live in `src/air_status/providers/`. A provider:
|
|
57
|
+
|
|
58
|
+
- `fetch()` — gets a raw reading from its portal,
|
|
59
|
+
- `parse(reading)` — turns it into the shared `FlightStatus` (`models.py`),
|
|
60
|
+
- `history(directory)` — returns its `FlightHistory` (`flightlog.py`), which
|
|
61
|
+
decides how readings are written and read back. Reuse `ChangeLog` for
|
|
62
|
+
change-only JSON Lines, or implement your own format.
|
|
63
|
+
|
|
64
|
+
Auto-detect (the default, `-p auto`) asks every provider's portal at once via
|
|
65
|
+
`detect()` and uses the first that returns flight data. The default `detect()`
|
|
66
|
+
is "fetch and parse succeed"; override it when portals are shared (Gogo
|
|
67
|
+
providers also check `airline_code`).
|
|
68
|
+
|
|
69
|
+
Same Wi-Fi system as an existing provider (e.g. another Gogo/Intelsat airline):
|
|
70
|
+
subclass it and set `id`, `name` and `airline_code`, like `aircanada.py`. Then add an instance
|
|
71
|
+
to `PROVIDERS` in `providers/__init__.py`.
|
|
72
|
+
|
|
73
|
+
## Tests and lint
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
.venv/bin/pytest
|
|
77
|
+
.venv/bin/ruff check .
|
|
78
|
+
.venv/bin/ruff format .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
GitHub Actions (`.github/workflows/ci.yml`) runs the same lint plus tests on
|
|
82
|
+
Python 3.9–3.14 for every push to `main` and every pull request.
|
|
83
|
+
|
|
84
|
+
## Releasing
|
|
85
|
+
|
|
86
|
+
1. Bump `__version__` in `src/air_status/__init__.py` and commit.
|
|
87
|
+
2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
|
|
88
|
+
|
|
89
|
+
`.github/workflows/release.yml` checks the tag matches the version, runs lint
|
|
90
|
+
and tests, builds the package, publishes it to PyPI (trusted publishing, no
|
|
91
|
+
token) and creates a GitHub release with the built files.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "air-status"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Live in-flight status from airline Wi-Fi portals, with a terminal map and flight history"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["rich>=13"]
|
|
14
|
+
keywords = ["flight", "in-flight wifi", "terminal", "tui", "map", "aviation"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/max2697/air-status"
|
|
27
|
+
Issues = "https://github.com/max2697/air-status/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8", "ruff>=0.6", "build>=1", "twine>=5"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
air-status = "air_status.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.dynamic]
|
|
36
|
+
version = { attr = "air_status.__version__" }
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 120
|
|
46
|
+
target-version = "py39"
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Braille-dot canvas: each terminal cell holds a 2x4 grid of dots."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
# Braille dot bit for (x, y) inside a 2x4 cell
|
|
8
|
+
_BITS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Canvas:
|
|
12
|
+
"""Draw dots and lines in dot coordinates (width*2 x height*4), render as rich Text."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, width: int, height: int):
|
|
15
|
+
self.width, self.height = width, height
|
|
16
|
+
self.bits = [[0] * width for _ in range(height)]
|
|
17
|
+
self.style: list = [[None] * width for _ in range(height)]
|
|
18
|
+
self.prio = [[0] * width for _ in range(height)]
|
|
19
|
+
self.chars: dict[tuple[int, int], tuple[str, Optional[str]]] = {}
|
|
20
|
+
|
|
21
|
+
def copy(self) -> "Canvas":
|
|
22
|
+
c = Canvas(self.width, self.height)
|
|
23
|
+
c.bits = [row[:] for row in self.bits]
|
|
24
|
+
c.style = [row[:] for row in self.style]
|
|
25
|
+
c.prio = [row[:] for row in self.prio]
|
|
26
|
+
c.chars = dict(self.chars)
|
|
27
|
+
return c
|
|
28
|
+
|
|
29
|
+
def dot(self, x: float, y: float, style: Optional[str], prio: int = 0) -> None:
|
|
30
|
+
"""Set one dot. When layers share a cell, the higher priority picks its colour."""
|
|
31
|
+
x, y = int(x), int(y)
|
|
32
|
+
if 0 <= x < self.width * 2 and 0 <= y < self.height * 4:
|
|
33
|
+
cx, cy = x // 2, y // 4
|
|
34
|
+
self.bits[cy][cx] |= _BITS[x % 2][y % 4]
|
|
35
|
+
if prio >= self.prio[cy][cx]:
|
|
36
|
+
self.style[cy][cx], self.prio[cy][cx] = style, prio
|
|
37
|
+
|
|
38
|
+
def line(self, x0: float, y0: float, x1: float, y1: float, style: Optional[str], prio: int = 0) -> None:
|
|
39
|
+
w, h = self.width * 2, self.height * 4
|
|
40
|
+
if (x0 < 0 and x1 < 0) or (y0 < 0 and y1 < 0) or (x0 >= w and x1 >= w) or (y0 >= h and y1 >= h):
|
|
41
|
+
return # fully off-screen
|
|
42
|
+
n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
|
|
43
|
+
for i in range(n + 1):
|
|
44
|
+
t = i / n
|
|
45
|
+
self.dot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, style, prio)
|
|
46
|
+
|
|
47
|
+
def overlay(self, other: "Canvas") -> None:
|
|
48
|
+
"""Replace this canvas's cells with every non-empty cell of `other`."""
|
|
49
|
+
for y in range(self.height):
|
|
50
|
+
for x in range(self.width):
|
|
51
|
+
if other.bits[y][x]:
|
|
52
|
+
self.bits[y][x], self.style[y][x] = other.bits[y][x], other.style[y][x]
|
|
53
|
+
|
|
54
|
+
def text(self, cx: int, cy: int, s: str, style: Optional[str]) -> None:
|
|
55
|
+
"""Write plain characters in cell coordinates (labels, icons)."""
|
|
56
|
+
for i, ch in enumerate(s):
|
|
57
|
+
if 0 <= cx + i < self.width and 0 <= cy < self.height:
|
|
58
|
+
self.chars[(cx + i, cy)] = (ch, style)
|
|
59
|
+
|
|
60
|
+
def to_text(self) -> Text:
|
|
61
|
+
out = Text()
|
|
62
|
+
for y in range(self.height):
|
|
63
|
+
for x in range(self.width):
|
|
64
|
+
if (x, y) in self.chars:
|
|
65
|
+
out.append(*self.chars[(x, y)])
|
|
66
|
+
else:
|
|
67
|
+
out.append(chr(0x2800 + self.bits[y][x]), self.style[y][x])
|
|
68
|
+
if y < self.height - 1:
|
|
69
|
+
out.append("\n")
|
|
70
|
+
return out
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Command line: `air-status` (live view) and `air-status history`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from air_status import __version__
|
|
10
|
+
from air_status.flight_map import FlightMap
|
|
11
|
+
from air_status.providers import AUTO, PROVIDERS, Provider, detect_provider, get_provider
|
|
12
|
+
from air_status.tui import Poller, history_table, run_live
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(prog="air-status", description="Live in-flight status with map and history.")
|
|
17
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--data-dir", type=Path, default=Path.cwd(), help="folder for history/ and mapdata/ (default: current folder)"
|
|
20
|
+
)
|
|
21
|
+
sub = parser.add_subparsers(dest="command")
|
|
22
|
+
live = sub.add_parser("live", help="live view, saves history (default)")
|
|
23
|
+
live.add_argument(
|
|
24
|
+
"-p",
|
|
25
|
+
"--provider",
|
|
26
|
+
choices=[AUTO, *sorted(PROVIDERS)],
|
|
27
|
+
default=AUTO,
|
|
28
|
+
help="airline / Wi-Fi system (default: auto, detected from the Wi-Fi you're on)",
|
|
29
|
+
)
|
|
30
|
+
live.add_argument(
|
|
31
|
+
"--every", type=int, metavar="SECONDS", help="refresh interval (default: the provider's own update rate)"
|
|
32
|
+
)
|
|
33
|
+
sub.add_parser("history", help="list saved flights")
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pick_provider(provider_id: str) -> Optional[Provider]:
|
|
38
|
+
if provider_id != AUTO:
|
|
39
|
+
return get_provider(provider_id)
|
|
40
|
+
print("Looking for an in-flight Wi-Fi portal…", flush=True)
|
|
41
|
+
provider = detect_provider()
|
|
42
|
+
if provider is None:
|
|
43
|
+
names = ", ".join(p.name for p in PROVIDERS.values())
|
|
44
|
+
print(f"No in-flight Wi-Fi portal found (tried {names}). Connect to the plane's Wi-Fi or pick one with -p.")
|
|
45
|
+
else:
|
|
46
|
+
print(f"Detected {provider.name}.", flush=True)
|
|
47
|
+
return provider
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
51
|
+
args = build_parser().parse_args(argv)
|
|
52
|
+
history_dir = args.data_dir / "history"
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "history":
|
|
55
|
+
table = history_table([p.history(history_dir / p.id) for p in PROVIDERS.values()])
|
|
56
|
+
if table is None:
|
|
57
|
+
print("No saved flights yet.")
|
|
58
|
+
else:
|
|
59
|
+
Console().print(table)
|
|
60
|
+
print(f"Files: {history_dir}")
|
|
61
|
+
else:
|
|
62
|
+
provider = pick_provider(getattr(args, "provider", AUTO))
|
|
63
|
+
if provider is None:
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
every = getattr(args, "every", None) or provider.update_interval
|
|
66
|
+
poller = Poller(provider, provider.history(history_dir / provider.id), every)
|
|
67
|
+
run_live(poller, FlightMap(args.data_dir / "mapdata"))
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
pass
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Terminal map of a flight: Natural Earth outlines, route and plane on a Braille canvas."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import urllib.request
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from air_status.braille import Canvas
|
|
15
|
+
from air_status.geo import LatLon, bearing_deg, distance_km, great_circle
|
|
16
|
+
from air_status.models import FlightStatus
|
|
17
|
+
|
|
18
|
+
SOURCE_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/{}.geojson"
|
|
19
|
+
# (dataset, style, priority) — a higher priority wins the colour of a shared cell
|
|
20
|
+
LAYERS = (
|
|
21
|
+
("ne_50m_admin_1_states_provinces_lines", "grey27", 1),
|
|
22
|
+
("ne_50m_admin_0_boundary_lines_land", "grey58", 2),
|
|
23
|
+
("ne_50m_lakes", "dodger_blue3", 3),
|
|
24
|
+
("ne_50m_coastline", "dodger_blue3", 3),
|
|
25
|
+
)
|
|
26
|
+
MAX_LAKE_RANK = 2 # only big lakes; small ones are noise at this scale
|
|
27
|
+
|
|
28
|
+
Path_ = list[list[float]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _paths(coords) -> list[Path_]:
|
|
32
|
+
"""Every [[lon, lat], ...] list inside any GeoJSON geometry."""
|
|
33
|
+
if coords and isinstance(coords[0][0], (int, float)):
|
|
34
|
+
return [coords]
|
|
35
|
+
return [p for c in coords for p in _paths(c)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_layers(map_dir: Path) -> list[tuple[list[Path_], str, int]]:
|
|
39
|
+
"""Load outlines, downloading each dataset once into map_dir."""
|
|
40
|
+
map_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
layers = []
|
|
42
|
+
for name, style, prio in LAYERS:
|
|
43
|
+
path = map_dir / f"{name}.geojson"
|
|
44
|
+
if not path.exists():
|
|
45
|
+
urllib.request.urlretrieve(SOURCE_URL.format(name), path)
|
|
46
|
+
features = json.loads(path.read_text())["features"]
|
|
47
|
+
if name == "ne_50m_lakes":
|
|
48
|
+
features = [f for f in features if f["properties"].get("scalerank", 9) <= MAX_LAKE_RANK]
|
|
49
|
+
layers.append(([p for f in features for p in _paths(f["geometry"]["coordinates"])], style, prio))
|
|
50
|
+
return layers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
PLANE_ARROWS = "↑↗→↘↓↙←↖" # terminals can't rotate ✈, so the map points with an arrow
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plane_glyph(dx: float, dy: float) -> str:
|
|
57
|
+
"""Arrow for a direction on screen (dy grows downwards)."""
|
|
58
|
+
if not dx and not dy:
|
|
59
|
+
return "✈"
|
|
60
|
+
angle = math.degrees(math.atan2(dx, -dy)) % 360 # clockwise from screen-up
|
|
61
|
+
return PLANE_ARROWS[round(angle / 45) % 8]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def heading_of(status: FlightStatus) -> float:
|
|
65
|
+
"""Reported heading, else the direction towards the destination."""
|
|
66
|
+
if status.heading is not None:
|
|
67
|
+
return status.heading
|
|
68
|
+
return bearing_deg(status.latitude, status.longitude, status.destination.latitude, status.destination.longitude)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
MAX_RECORDED_GAP = 60 # seconds; longer gaps between saved points are drawn as estimated
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Leg:
|
|
76
|
+
"""Part of the flown path: `recorded` legs come from history, the rest are estimated."""
|
|
77
|
+
|
|
78
|
+
points: list[LatLon]
|
|
79
|
+
recorded: bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def geoline(a: LatLon, b: LatLon, step_km: float = 20) -> list[LatLon]:
|
|
83
|
+
"""Shortest path on the globe from a to b, one point per `step_km`."""
|
|
84
|
+
n = max(1, int(distance_km(*a, *b) / step_km))
|
|
85
|
+
return great_circle(*a, *b, n=n)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def flown_legs(status: FlightStatus, track: Sequence[FlightStatus] = ()) -> list[Leg]:
|
|
89
|
+
"""Path from the origin to the current position.
|
|
90
|
+
|
|
91
|
+
Consecutive saved points less than MAX_RECORDED_GAP apart are joined as recorded;
|
|
92
|
+
everything else (origin → first point, gaps, no history at all) is a geoline
|
|
93
|
+
between the two known points, marked as estimated.
|
|
94
|
+
"""
|
|
95
|
+
known: list[tuple[LatLon, Optional[datetime]]] = [((status.origin.latitude, status.origin.longitude), None)]
|
|
96
|
+
known += [((t.latitude, t.longitude), t.time) for t in track if t.time <= status.time]
|
|
97
|
+
if known[-1][1] != status.time:
|
|
98
|
+
known.append(((status.latitude, status.longitude), status.time))
|
|
99
|
+
|
|
100
|
+
legs: list[Leg] = []
|
|
101
|
+
for (a, ta), (b, tb) in zip(known, known[1:]):
|
|
102
|
+
recorded = ta is not None and tb is not None and (tb - ta).total_seconds() <= MAX_RECORDED_GAP
|
|
103
|
+
if recorded and legs and legs[-1].recorded:
|
|
104
|
+
legs[-1].points.append(b) # extend the current recorded run
|
|
105
|
+
elif recorded:
|
|
106
|
+
legs.append(Leg([a, b], True))
|
|
107
|
+
else:
|
|
108
|
+
legs.append(Leg(geoline(a, b), False))
|
|
109
|
+
return legs
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FlightMap:
|
|
113
|
+
"""Draws the base map once per route and size, then overlays route and plane each frame."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, map_dir: Path):
|
|
116
|
+
self.map_dir = map_dir
|
|
117
|
+
self._layers: Optional[list] = None
|
|
118
|
+
self._key: Optional[tuple] = None
|
|
119
|
+
self._base: Optional[Canvas] = None
|
|
120
|
+
self._route_key: Optional[tuple] = None
|
|
121
|
+
self._route: Optional[Canvas] = None
|
|
122
|
+
|
|
123
|
+
def _setup(self, status: FlightStatus, width: int, height: int) -> None:
|
|
124
|
+
if self._layers is None:
|
|
125
|
+
self._layers = load_layers(self.map_dir)
|
|
126
|
+
o, d = status.origin, status.destination
|
|
127
|
+
route = great_circle(o.latitude, o.longitude, d.latitude, d.longitude)
|
|
128
|
+
lats = [p[0] for p in route]
|
|
129
|
+
lons = [p[1] for p in route]
|
|
130
|
+
mid_lat = (max(lats) + min(lats)) / 2
|
|
131
|
+
mid_lon = (max(lons) + min(lons)) / 2
|
|
132
|
+
kx = math.cos(math.radians(mid_lat)) # squash longitude so shapes look right
|
|
133
|
+
pw, ph = width * 2, height * 4
|
|
134
|
+
span_x = (max(lons) - min(lons)) * kx * 1.3 + 1
|
|
135
|
+
span_y = (max(lats) - min(lats)) * 1.3 + 1
|
|
136
|
+
scale = min(pw / span_x, ph / span_y)
|
|
137
|
+
|
|
138
|
+
def project(lat: float, lon: float) -> tuple[float, float]:
|
|
139
|
+
return pw / 2 + (lon - mid_lon) * kx * scale, ph / 2 - (lat - mid_lat) * scale
|
|
140
|
+
|
|
141
|
+
self._project = project
|
|
142
|
+
base = Canvas(width, height)
|
|
143
|
+
for paths, style, prio in self._layers:
|
|
144
|
+
for path in paths:
|
|
145
|
+
pts = [project(lat, lon) for lon, lat, *_ in path]
|
|
146
|
+
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
|
147
|
+
base.line(x0, y0, x1, y1, style, prio)
|
|
148
|
+
self._base = base
|
|
149
|
+
|
|
150
|
+
def _draw_route(self, status: FlightStatus, track: Sequence[FlightStatus], width: int, height: int) -> Canvas:
|
|
151
|
+
project = self._project
|
|
152
|
+
here = (status.latitude, status.longitude)
|
|
153
|
+
dst = (status.destination.latitude, status.destination.longitude)
|
|
154
|
+
route = Canvas(width, height)
|
|
155
|
+
|
|
156
|
+
ahead = [project(*p) for p in geoline(here, dst)]
|
|
157
|
+
for i, ((x0, y0), (x1, y1)) in enumerate(zip(ahead, ahead[1:])):
|
|
158
|
+
if i % 2 == 0: # dashed remaining leg
|
|
159
|
+
route.line(x0, y0, x1, y1, "bright_white", 8)
|
|
160
|
+
|
|
161
|
+
for leg in flown_legs(status, track):
|
|
162
|
+
pts = [project(*p) for p in leg.points]
|
|
163
|
+
for i, ((x0, y0), (x1, y1)) in enumerate(zip(pts, pts[1:])):
|
|
164
|
+
if leg.recorded:
|
|
165
|
+
route.line(x0, y0, x1, y1, "bold red", 10)
|
|
166
|
+
elif i % 2 == 0: # dashed: estimated, not from history
|
|
167
|
+
route.line(x0, y0, x1, y1, "red3", 9)
|
|
168
|
+
return route
|
|
169
|
+
|
|
170
|
+
def render(self, status: FlightStatus, width: int, height: int, track: Sequence[FlightStatus] = ()) -> Text:
|
|
171
|
+
"""Map for one status, `width` x `height` terminal cells.
|
|
172
|
+
|
|
173
|
+
`track` is this flight's saved history; without it the flown path is estimated.
|
|
174
|
+
"""
|
|
175
|
+
dep_code, dst_code = status.origin.code, status.destination.code
|
|
176
|
+
key = (dep_code, dst_code, width, height)
|
|
177
|
+
if key != self._key:
|
|
178
|
+
self._setup(status, width, height)
|
|
179
|
+
self._key = key
|
|
180
|
+
route_key = (key, status.time, len(track))
|
|
181
|
+
if route_key != self._route_key: # redraw the route only when data changes
|
|
182
|
+
self._route = self._draw_route(status, track, width, height)
|
|
183
|
+
self._route_key = route_key
|
|
184
|
+
canvas = self._base.copy()
|
|
185
|
+
canvas.overlay(self._route) # route stays crisp over borders
|
|
186
|
+
project = self._project
|
|
187
|
+
here = (status.latitude, status.longitude)
|
|
188
|
+
dep = (status.origin.latitude, status.origin.longitude)
|
|
189
|
+
dst = (status.destination.latitude, status.destination.longitude)
|
|
190
|
+
|
|
191
|
+
for code, (lat, lon) in ((dep_code, dep), (dst_code, dst)):
|
|
192
|
+
x, y = project(lat, lon)
|
|
193
|
+
cx, cy = int(x) // 2, int(y) // 4
|
|
194
|
+
canvas.text(cx, cy, "●", "bold white")
|
|
195
|
+
label = f" {code} "
|
|
196
|
+
lx = cx + 2 if cx + 2 + len(label) <= width else cx - 1 - len(label)
|
|
197
|
+
canvas.text(lx, cy, label, "bold white on red")
|
|
198
|
+
|
|
199
|
+
x, y = project(*here)
|
|
200
|
+
# Step a little along the heading and see which way that goes on screen
|
|
201
|
+
h = math.radians(heading_of(status))
|
|
202
|
+
step = 0.1 # degrees
|
|
203
|
+
ahead_lon = status.longitude + step * math.sin(h) / max(0.01, math.cos(math.radians(status.latitude)))
|
|
204
|
+
ax, ay = project(status.latitude + step * math.cos(h), ahead_lon)
|
|
205
|
+
canvas.text(int(x) // 2, int(y) // 4, plane_glyph(ax - x, ay - y), "bold yellow")
|
|
206
|
+
return canvas.to_text()
|