wifi-observer 0.1.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.
- wifi_observer-0.1.0.dist-info/METADATA +391 -0
- wifi_observer-0.1.0.dist-info/RECORD +8 -0
- wifi_observer-0.1.0.dist-info/WHEEL +5 -0
- wifi_observer-0.1.0.dist-info/entry_points.txt +2 -0
- wifi_observer-0.1.0.dist-info/licenses/LICENSE +21 -0
- wifi_observer-0.1.0.dist-info/top_level.txt +2 -0
- wifi_observer.py +652 -0
- wifi_observer_plot.py +138 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wifi-observer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal tool that monitors internet reachability and WiFi signal strength once per second, with a live UI, JSON logging, and matplotlib graphs.
|
|
5
|
+
Author: biswanathamz
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/biswanathamz/wifi-observer
|
|
8
|
+
Project-URL: Repository, https://github.com/biswanathamz/wifi-observer
|
|
9
|
+
Project-URL: Issues, https://github.com/biswanathamz/wifi-observer/issues
|
|
10
|
+
Keywords: wifi,network,monitor,ping,latency,signal,terminal,tui
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: graph
|
|
28
|
+
Requires-Dist: matplotlib; extra == "graph"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: ruff; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<div align="center">
|
|
35
|
+
|
|
36
|
+
# ๐ถ WiFi Observer
|
|
37
|
+
|
|
38
|
+
**A terminal tool that tells you โ every second โ whether your problem is the _internet_ or your _WiFi_.**
|
|
39
|
+
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
[](https://www.python.org/)
|
|
42
|
+
[](#requirements)
|
|
43
|
+
[](#contributing)
|
|
44
|
+
[](#how-it-works)
|
|
45
|
+
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
WiFi Observer continuously monitors two **independent** things โ internet
|
|
49
|
+
reachability (via ping) and WiFi signal strength (in dBm) โ so you can tell *why*
|
|
50
|
+
your connection is bad, not just *that* it is. It runs entirely in your terminal,
|
|
51
|
+
keeps a live history in memory, logs every sample to JSON, and draws a graph of
|
|
52
|
+
the session. Built with the Python standard library + Bash; the only optional
|
|
53
|
+
dependency is matplotlib (for graphs), installed automatically on first run.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## ๐ Table of contents
|
|
58
|
+
|
|
59
|
+
- [Why](#why)
|
|
60
|
+
- [Demo](#demo)
|
|
61
|
+
- [Features](#features)
|
|
62
|
+
- [Installation](#installation)
|
|
63
|
+
- [Usage](#usage)
|
|
64
|
+
- [Configuration](#configuration)
|
|
65
|
+
- [How it works](#how-it-works)
|
|
66
|
+
- [Output & file formats](#output--file-formats)
|
|
67
|
+
- [Reading the numbers](#reading-the-numbers)
|
|
68
|
+
- [Project structure](#project-structure)
|
|
69
|
+
- [Requirements](#requirements)
|
|
70
|
+
- [Roadmap](#roadmap)
|
|
71
|
+
- [Contributing](#contributing)
|
|
72
|
+
- [License](#license)
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Why
|
|
77
|
+
|
|
78
|
+
A plain ping test lumps two different failures together. WiFi Observer separates
|
|
79
|
+
them:
|
|
80
|
+
|
|
81
|
+
| WiFi signal | Internet | Likely culprit |
|
|
82
|
+
|-------------|----------|----------------|
|
|
83
|
+
| Strong & stable | Down / lossy | **ISP / router** โ not your WiFi |
|
|
84
|
+
| Weak / unstable | Down / lossy | **Your WiFi** โ distance, walls, interference |
|
|
85
|
+
| Strong & stable | Fine | All good โ
|
|
|
86
|
+
|
|
87
|
+
So instead of "the internet is slow," you get an answer you can act on.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Demo
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
โโ WiFi Observer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
95
|
+
SSID: HomeNet-5G iface: wlan0
|
|
96
|
+
elapsed: 00:04:12 remaining: 00:05:48
|
|
97
|
+
log โ logs/2026-06-28/wifi-215205.jsonl
|
|
98
|
+
|
|
99
|
+
INTERNET โ UP target 8.8.8.8
|
|
100
|
+
latency : last 23.4 ms avg 26.1 min 18.0 max 142.0 ms
|
|
101
|
+
quality : packet loss 0.8% (2/250) jitter 5.2 ms
|
|
102
|
+
outages : 2 downtime 00:04 longest 00:03
|
|
103
|
+
|
|
104
|
+
WIFI SIGNAL -47 dBm (Excellent)
|
|
105
|
+
stability : STABLE (ยฑ1.8 dBm) min -52 max -44 avg -47.3 dBm
|
|
106
|
+
|
|
107
|
+
latency โโโโโโโโโยทโโโโ (ยท = a probe with no reply / outage)
|
|
108
|
+
signal โ
โ
โโโ
โ
โโ
โ
โโโ
โโ
|
|
109
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
110
|
+
[g] save graph [q] quit (Ctrl+C also quits)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The exported graph stacks **internet latency** (outages marked in red) over
|
|
114
|
+
**WiFi signal strength** (with quality reference lines), sharing a time axis.
|
|
115
|
+
|
|
116
|
+
> ๐ก Tip: drop a real screenshot/GIF here (e.g. `docs/demo.png`) before publishing.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Features
|
|
121
|
+
|
|
122
|
+
- ๐ก **Two metrics at once** โ internet reachability *and* WiFi signal, sampled every second.
|
|
123
|
+
- ๐ฅ๏ธ **Live terminal UI** โ single self-refreshing screen with two labelled sections and sparklines.
|
|
124
|
+
- โฑ๏ธ **Pick a duration on start** โ 1 min / 10 min / 1 hr / until you close it, with a live countdown.
|
|
125
|
+
- โจ๏ธ **Interactive keys** โ `g` saves a graph instantly, `q` quits.
|
|
126
|
+
- ๐ **JSON logging** โ every sample appended (JSON-lines), crash-safe.
|
|
127
|
+
- ๐ **Graphs ("maps")** โ matplotlib charts generated from inside the app and on exit.
|
|
128
|
+
- ๐๏ธ **Date-organised output** โ logs, summary, and graph grouped under `logs/YYYY-MM-DD/`.
|
|
129
|
+
- ๐ง **Near-zero setup** โ monitor is stdlib-only; matplotlib is auto-installed into a local `.venv`.
|
|
130
|
+
- ๐ **Degrades gracefully** โ no colours? no matplotlib? not a TTY? It still runs and tells you.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Installation
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# 1. Clone
|
|
138
|
+
git clone https://github.com/biswanathamz/wifi-observer.git
|
|
139
|
+
cd wifi-observer
|
|
140
|
+
|
|
141
|
+
# 2. Run โ that's it.
|
|
142
|
+
./run.sh
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
There is **no build step**. The monitor uses only the Python 3 standard library
|
|
146
|
+
and the system `ping`. The first run bootstraps a local `.venv` and installs
|
|
147
|
+
`matplotlib` so graphs work (skip with `./run.sh --no-graph-setup`).
|
|
148
|
+
|
|
149
|
+
> Prefer to manage deps yourself? `pip install matplotlib` and run
|
|
150
|
+
> `python3 wifi_observer.py` directly.
|
|
151
|
+
|
|
152
|
+
### Install as a command (optional)
|
|
153
|
+
|
|
154
|
+
Install it as a proper CLI so `wifi-observer` is on your `PATH`:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install . # core only (stdlib monitor)
|
|
158
|
+
pip install '.[graph]' # + matplotlib, for the `g` graph feature
|
|
159
|
+
wifi-observer --help
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Without the `graph` extra the monitor still runs fully; graphs are simply
|
|
163
|
+
skipped until matplotlib is present.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Usage
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
./run.sh # ping 8.8.8.8 every 1s
|
|
171
|
+
./run.sh -H 1.1.1.1 -i 2 # ping Cloudflare, every 2 seconds
|
|
172
|
+
./run.sh -H 192.168.1.1 # ping your router (tests the LAN link only)
|
|
173
|
+
./run.sh --no-color # plain text
|
|
174
|
+
./run.sh --help # full option list
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**On start**, choose how long to run:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
WiFi Observer โ how long should it run?
|
|
181
|
+
[1] 1 minute
|
|
182
|
+
[2] 10 minutes
|
|
183
|
+
[3] 1 hour
|
|
184
|
+
[4] Until I close it
|
|
185
|
+
Select 1-4 (default 4):
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**While running**, the UI is interactive:
|
|
189
|
+
|
|
190
|
+
| Key | Action |
|
|
191
|
+
|-----|--------|
|
|
192
|
+
| `g` | Generate & save a graph (PNG) right now |
|
|
193
|
+
| `q` | Quit and print the session summary |
|
|
194
|
+
| `Ctrl+C` | Also quits cleanly |
|
|
195
|
+
|
|
196
|
+
A graph is **also saved automatically on exit**, and everything lands under
|
|
197
|
+
`logs/<today>/`.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Configuration
|
|
202
|
+
|
|
203
|
+
| Flag | Default | Description |
|
|
204
|
+
|------|---------|-------------|
|
|
205
|
+
| `-H, --host` | `8.8.8.8` | Host/IP to ping (an IP avoids DNS; a hostname is resolved by `ping`). |
|
|
206
|
+
| `-i, --interval` | `1.0` | Seconds between samples. |
|
|
207
|
+
| `-t, --timeout` | `1.0` | Per-ping timeout (s) โ a slow/no reply counts as DOWN. |
|
|
208
|
+
| `-n, --history` | `3600` | Samples kept in memory for the live UI / sparklines. |
|
|
209
|
+
| `--log PATH` | auto | Custom JSON-lines log path (default `logs/<date>/wifi-<time>.jsonl`). |
|
|
210
|
+
| `--no-log` | off | Disable JSON logging. |
|
|
211
|
+
| `--no-color` | off | Plain text, no ANSI colours. |
|
|
212
|
+
| `--no-graph-setup` | off | *(run.sh)* Skip the matplotlib `.venv` bootstrap. |
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## How it works
|
|
217
|
+
|
|
218
|
+
### Architecture
|
|
219
|
+
|
|
220
|
+
| File | Language | Role |
|
|
221
|
+
|------|----------|------|
|
|
222
|
+
| [run.sh](run.sh) | Bash | Checks deps, bootstraps matplotlib into `.venv`, launches the app. |
|
|
223
|
+
| [wifi_observer.py](wifi_observer.py) | Python | Monitor loop, measurements, statistics, interactive UI, JSON logging. |
|
|
224
|
+
| [wifi_observer_plot.py](wifi_observer_plot.py) | Python | Builds the matplotlib figure; used by the app and standalone. |
|
|
225
|
+
|
|
226
|
+
### The per-second cycle
|
|
227
|
+
|
|
228
|
+
1. **Ping** the host once โ reachable? + latency.
|
|
229
|
+
2. **Read WiFi signal** from `/proc/net/wireless` โ dBm + link quality.
|
|
230
|
+
3. Build a sample record and append it to the **in-memory history** and the **JSON log**.
|
|
231
|
+
4. Update **running statistics** incrementally (counts, sums, sums-of-squares).
|
|
232
|
+
5. **Redraw** the UI.
|
|
233
|
+
6. Wait out the interval while **polling the keyboard** for `g`/`q`.
|
|
234
|
+
|
|
235
|
+
### How the ping works
|
|
236
|
+
|
|
237
|
+
No hand-rolled ICMP โ it runs the system `ping`:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
ping -c 1 -W <timeout> <host>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`-c 1` sends one echo request; `-W` is the reply timeout. UP/DOWN is decided from
|
|
244
|
+
ping's **exit code** (0 = reply); latency is parsed from the output (`time=23.4 ms`)
|
|
245
|
+
with a regex. Because the OS `ping` already holds the ICMP privilege, **no `sudo`
|
|
246
|
+
is needed**. The call is wrapped so a hang or failure can never crash the loop โ it
|
|
247
|
+
just records a DOWN sample.
|
|
248
|
+
|
|
249
|
+
### How the WiFi signal is read
|
|
250
|
+
|
|
251
|
+
The kernel exposes live wireless stats as text in `/proc/net/wireless`. The program
|
|
252
|
+
parses the active interface's row for **link quality** (โ out of 70 โ a 0โ100 %
|
|
253
|
+
figure) and **signal level in dBm**. No external command, no privileges.
|
|
254
|
+
|
|
255
|
+
### Statistics
|
|
256
|
+
|
|
257
|
+
Aggregates are maintained incrementally so each tick is O(1) no matter how long
|
|
258
|
+
you run: uptime %, packet loss %, latency avg/min/max, **jitter** (latency
|
|
259
|
+
std-dev), outage count/total/longest, and signal avg/min/max with **std-dev**
|
|
260
|
+
(which drives the stability verdict).
|
|
261
|
+
|
|
262
|
+
History is a bounded `deque` (default last **3600** samples โ 1 h), so memory stays
|
|
263
|
+
capped โ while the **JSON log keeps the full session** on disk, and graphs are
|
|
264
|
+
built from that log.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Output & file formats
|
|
269
|
+
|
|
270
|
+
Each run writes into a **date folder**, all artifacts sharing one basename:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
logs/
|
|
274
|
+
โโโ 2026-06-28/
|
|
275
|
+
โโโ wifi-215205.jsonl # one JSON object per sample
|
|
276
|
+
โโโ wifi-215205.summary.json # session aggregates
|
|
277
|
+
โโโ wifi-215205.png # the graph ("map")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Per-sample log line** (`.jsonl`):
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
{"ts": 1782662252.80, "time": "2026-06-28T21:27:32.802", "internet_up": true,
|
|
284
|
+
"latency_ms": 47.8, "signal_dbm": -63.0, "signal_quality": 67.1, "ssid": "HomeNet-5G"}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**Summary** (`.summary.json`) โ start/end/duration, config, plus `internet` and
|
|
288
|
+
`wifi_signal` blocks (uptime %, packet loss, latency stats, outages, signal
|
|
289
|
+
avg/min/max/stddev/stability).
|
|
290
|
+
|
|
291
|
+
Re-plot any old log standalone:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
python3 wifi_observer_plot.py # newest log โ <log>.png
|
|
295
|
+
python3 wifi_observer_plot.py logs/2026-06-28/wifi-215205.jsonl -o report.png --show
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Reading the numbers
|
|
301
|
+
|
|
302
|
+
**WiFi signal (dBm)** โ higher (closer to 0) is better:
|
|
303
|
+
|
|
304
|
+
| dBm | Label | Meaning |
|
|
305
|
+
|-----|-------|---------|
|
|
306
|
+
| โฅ -50 | Excellent | Right next to the router |
|
|
307
|
+
| -50 to -60 | Good | Solid, reliable |
|
|
308
|
+
| -60 to -67 | Fair | Usable; HD video fine |
|
|
309
|
+
| -67 to -75 | Weak | Drops/slowdowns likely |
|
|
310
|
+
| < -75 | Very weak | Often unusable |
|
|
311
|
+
|
|
312
|
+
**Stability** (from signal std-dev): `STABLE` < 3 dBm, `FLUCTUATING` < 6 dBm,
|
|
313
|
+
`UNSTABLE` โฅ 6 dBm. **Latency / jitter / packet loss** โ lower is better; high
|
|
314
|
+
jitter hurts calls/gaming even when average latency looks fine.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Project structure
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
wifi-observer/
|
|
322
|
+
โโโ doc/
|
|
323
|
+
โ โโโ SPEC.md # full specification
|
|
324
|
+
โโโ run.sh # Bash launcher (+ matplotlib venv bootstrap)
|
|
325
|
+
โโโ wifi_observer.py # Python monitor, UI, logging
|
|
326
|
+
โโโ wifi_observer_plot.py # matplotlib figure builder (UI + standalone)
|
|
327
|
+
โโโ LICENSE # MIT
|
|
328
|
+
โโโ README.md
|
|
329
|
+
โโโ .venv/ # auto-created on first run [git-ignored]
|
|
330
|
+
โโโ logs/ # date folders of output [git-ignored]
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Requirements
|
|
336
|
+
|
|
337
|
+
- **Linux** with `python3` (3.8+) and `ping` on `PATH`. The monitor itself is
|
|
338
|
+
**standard-library only**.
|
|
339
|
+
- **WiFi signal** needs `/proc/net/wireless` (standard on Linux WiFi). SSID/
|
|
340
|
+
interface detection uses `iwgetid`/`nmcli` when present; otherwise it shows `?`.
|
|
341
|
+
- **matplotlib** โ only for graphs; `run.sh` installs it into a local `.venv` on
|
|
342
|
+
first run (needs `python3-venv` + network once). Skip with `--no-graph-setup`.
|
|
343
|
+
|
|
344
|
+
> **Platform note:** Linux-focused. The `ping` flags and signal reading target
|
|
345
|
+
> Linux; macOS/Windows support would need contributions (see the roadmap).
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Roadmap
|
|
350
|
+
|
|
351
|
+
Ideas and good first contributions:
|
|
352
|
+
|
|
353
|
+
- [ ] macOS / Windows support (`ping` flags + signal reading)
|
|
354
|
+
- [ ] Optional gateway probe to pinpoint LAN-vs-WAN faults
|
|
355
|
+
- [ ] DNS-resolution check (distinguish "internet down" from "DNS down")
|
|
356
|
+
- [ ] Desktop notification on outage start / recovery
|
|
357
|
+
- [ ] CSV export and a `--summary-only` headless mode
|
|
358
|
+
- [ ] Configurable thresholds (signal labels, stability bands)
|
|
359
|
+
|
|
360
|
+
Found a bug or want a feature? Please [open an issue](../../issues).
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Contributing
|
|
365
|
+
|
|
366
|
+
Contributions are welcome! ๐
|
|
367
|
+
|
|
368
|
+
1. **Fork** the repo and create a branch: `git checkout -b feature/my-change`.
|
|
369
|
+
2. **Make your change.** Keep the monitor **standard-library only** (matplotlib is
|
|
370
|
+
fine to import *lazily* inside `wifi_observer_plot.py`). Match the existing style.
|
|
371
|
+
3. **Test it** โ run `./run.sh` and verify the live UI, logging, and a generated
|
|
372
|
+
graph. For unreachable-host behaviour, try `./run.sh -H 192.0.2.1` (TEST-NET).
|
|
373
|
+
4. **Commit** with a clear message and **open a Pull Request** describing what and
|
|
374
|
+
why.
|
|
375
|
+
|
|
376
|
+
Please keep changes focused, document new flags in the README, and be kind in
|
|
377
|
+
code review. By contributing, you agree your work is licensed under the project's
|
|
378
|
+
MIT license.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## License
|
|
383
|
+
|
|
384
|
+
Released under the [MIT License](LICENSE) โ free to use, modify, and distribute
|
|
385
|
+
with attribution. ยฉ 2026 biswanathamz.
|
|
386
|
+
|
|
387
|
+
<div align="center">
|
|
388
|
+
|
|
389
|
+
โญ If this saved you a frustrating "is it me or the internet?" moment, consider starring the repo.
|
|
390
|
+
|
|
391
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
wifi_observer.py,sha256=XcVupLAItwIuXwiGwmXBMd3npNzrMrnltEKT-RpsKMs,25097
|
|
2
|
+
wifi_observer_plot.py,sha256=Bc6cKVQk7vAvQJWXEl-QVFQfVCTlGxuXJ9PmEQ8piWI,5086
|
|
3
|
+
wifi_observer-0.1.0.dist-info/licenses/LICENSE,sha256=pCxO2maGZZtmtxS4sOnpvBnEvwopdbLnPFPSnwTkNm0,1069
|
|
4
|
+
wifi_observer-0.1.0.dist-info/METADATA,sha256=ocDlTbTPz7ZL_t9a6e9nhIqhMLyKNkC-H_7QbHn85do,14253
|
|
5
|
+
wifi_observer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
wifi_observer-0.1.0.dist-info/entry_points.txt,sha256=PrtzvZU3kWJMhdNMpoH35HF2SFC_vHuP9fKbMcIS5Zc,53
|
|
7
|
+
wifi_observer-0.1.0.dist-info/top_level.txt,sha256=I9MMq84fqBOrwGjgTjN7Gn9HzlswjkNrYEYYnIHlqeg,33
|
|
8
|
+
wifi_observer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 biswanathamz
|
|
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.
|
wifi_observer.py
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WiFi Observer โ terminal-based connectivity & signal monitor.
|
|
3
|
+
|
|
4
|
+
Each interval it measures two independent things:
|
|
5
|
+
|
|
6
|
+
1. INTERNET โ pings a reliable host (default 8.8.8.8, Google DNS) to tell
|
|
7
|
+
whether the internet is reachable, and how fast (latency, packet loss).
|
|
8
|
+
2. WIFI SIGNAL โ reads the radio signal strength in dBm from the adapter
|
|
9
|
+
(/proc/net/wireless) to tell whether the WiFi link is strong and stable.
|
|
10
|
+
|
|
11
|
+
Every sample is appended to a JSON-lines log, and a summary JSON is written on
|
|
12
|
+
exit. Use plot.py to render graphs from the log with matplotlib.
|
|
13
|
+
|
|
14
|
+
Core uses the Python 3 stdlib + system `ping` only. See doc/SPEC.md.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import collections
|
|
21
|
+
import datetime as dt
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import signal
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import asdict, dataclass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
# Data model
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
@dataclass
|
|
38
|
+
class Probe:
|
|
39
|
+
ts: float # epoch seconds
|
|
40
|
+
time: str # ISO-8601 local timestamp (for the log)
|
|
41
|
+
internet_up: bool # internet host reachable?
|
|
42
|
+
latency_ms: float | None # round-trip ms (None when down)
|
|
43
|
+
signal_dbm: float | None # WiFi signal level in dBm (None if unknown)
|
|
44
|
+
signal_quality: float | None # link quality 0-100% (None if unknown)
|
|
45
|
+
ssid: str # network name at sample time
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _stddev(n: int, s: float, ss: float) -> float:
|
|
49
|
+
"""Population standard deviation from count, sum, sum-of-squares."""
|
|
50
|
+
if n < 2:
|
|
51
|
+
return 0.0
|
|
52
|
+
var = (ss - s * s / n) / n
|
|
53
|
+
return math.sqrt(var) if var > 0 else 0.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Stats:
|
|
57
|
+
"""Incrementally-maintained session aggregates for both metrics."""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
# --- internet ---
|
|
61
|
+
self.total = 0
|
|
62
|
+
self.up_count = 0
|
|
63
|
+
self.down_count = 0
|
|
64
|
+
self.outages = 0
|
|
65
|
+
self.downtime_s = 0.0
|
|
66
|
+
self.longest_outage_s = 0.0
|
|
67
|
+
self.lat_last: float | None = None
|
|
68
|
+
self.lat_min: float | None = None
|
|
69
|
+
self.lat_max: float | None = None
|
|
70
|
+
self._lat_n = 0
|
|
71
|
+
self._lat_s = 0.0
|
|
72
|
+
self._lat_ss = 0.0
|
|
73
|
+
# streak / outage bookkeeping
|
|
74
|
+
self.current_up: bool | None = None
|
|
75
|
+
self.streak_start = time.time()
|
|
76
|
+
self._cur_outage_start: float | None = None
|
|
77
|
+
|
|
78
|
+
# --- wifi signal (dBm) ---
|
|
79
|
+
self.sig_last: float | None = None
|
|
80
|
+
self.sig_min: float | None = None
|
|
81
|
+
self.sig_max: float | None = None
|
|
82
|
+
self._sig_n = 0
|
|
83
|
+
self._sig_s = 0.0
|
|
84
|
+
self._sig_ss = 0.0
|
|
85
|
+
|
|
86
|
+
# ---- internet derived ----
|
|
87
|
+
@property
|
|
88
|
+
def lat_avg(self) -> float | None:
|
|
89
|
+
return self._lat_s / self._lat_n if self._lat_n else None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def lat_jitter(self) -> float:
|
|
93
|
+
return _stddev(self._lat_n, self._lat_s, self._lat_ss)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def uptime_pct(self) -> float:
|
|
97
|
+
return 100.0 * self.up_count / self.total if self.total else 0.0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def loss_pct(self) -> float:
|
|
101
|
+
return 100.0 * self.down_count / self.total if self.total else 0.0
|
|
102
|
+
|
|
103
|
+
# ---- signal derived ----
|
|
104
|
+
@property
|
|
105
|
+
def sig_avg(self) -> float | None:
|
|
106
|
+
return self._sig_s / self._sig_n if self._sig_n else None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def sig_std(self) -> float:
|
|
110
|
+
return _stddev(self._sig_n, self._sig_s, self._sig_ss)
|
|
111
|
+
|
|
112
|
+
def add(self, p: Probe) -> None:
|
|
113
|
+
self.total += 1
|
|
114
|
+
|
|
115
|
+
# --- internet ---
|
|
116
|
+
if p.internet_up:
|
|
117
|
+
self.up_count += 1
|
|
118
|
+
self.lat_last = p.latency_ms
|
|
119
|
+
if p.latency_ms is not None:
|
|
120
|
+
self._lat_n += 1
|
|
121
|
+
self._lat_s += p.latency_ms
|
|
122
|
+
self._lat_ss += p.latency_ms * p.latency_ms
|
|
123
|
+
self.lat_min = p.latency_ms if self.lat_min is None else min(self.lat_min, p.latency_ms)
|
|
124
|
+
self.lat_max = p.latency_ms if self.lat_max is None else max(self.lat_max, p.latency_ms)
|
|
125
|
+
else:
|
|
126
|
+
self.down_count += 1
|
|
127
|
+
self.lat_last = None
|
|
128
|
+
|
|
129
|
+
if self.current_up is None:
|
|
130
|
+
self.current_up = p.internet_up
|
|
131
|
+
self.streak_start = p.ts
|
|
132
|
+
if not p.internet_up:
|
|
133
|
+
self.outages += 1
|
|
134
|
+
self._cur_outage_start = p.ts
|
|
135
|
+
elif p.internet_up != self.current_up:
|
|
136
|
+
self.current_up = p.internet_up
|
|
137
|
+
self.streak_start = p.ts
|
|
138
|
+
if not p.internet_up:
|
|
139
|
+
self.outages += 1
|
|
140
|
+
self._cur_outage_start = p.ts
|
|
141
|
+
else:
|
|
142
|
+
self._cur_outage_start = None
|
|
143
|
+
|
|
144
|
+
if not p.internet_up and self._cur_outage_start is not None:
|
|
145
|
+
self.longest_outage_s = max(self.longest_outage_s, p.ts - self._cur_outage_start)
|
|
146
|
+
|
|
147
|
+
# --- signal ---
|
|
148
|
+
if p.signal_dbm is not None:
|
|
149
|
+
self.sig_last = p.signal_dbm
|
|
150
|
+
self._sig_n += 1
|
|
151
|
+
self._sig_s += p.signal_dbm
|
|
152
|
+
self._sig_ss += p.signal_dbm * p.signal_dbm
|
|
153
|
+
self.sig_min = p.signal_dbm if self.sig_min is None else min(self.sig_min, p.signal_dbm)
|
|
154
|
+
self.sig_max = p.signal_dbm if self.sig_max is None else max(self.sig_max, p.signal_dbm)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------- #
|
|
158
|
+
# Measurements
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
_LAT_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def ping_once(host: str, timeout: float) -> tuple[bool, float | None]:
|
|
164
|
+
"""Single ping. Returns (reachable, latency_ms)."""
|
|
165
|
+
timeout_s = max(1, int(round(timeout)))
|
|
166
|
+
cmd = ["ping", "-c", "1", "-W", str(timeout_s), host]
|
|
167
|
+
try:
|
|
168
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
169
|
+
timeout=timeout + 2.0, text=True)
|
|
170
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
171
|
+
return False, None
|
|
172
|
+
if proc.returncode != 0:
|
|
173
|
+
return False, None
|
|
174
|
+
m = _LAT_RE.search(proc.stdout)
|
|
175
|
+
return True, (float(m.group(1)) if m else None)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def read_wifi_signal(iface: str) -> tuple[float | None, float | None]:
|
|
179
|
+
"""Read (signal_dbm, quality_pct) from /proc/net/wireless. No root needed."""
|
|
180
|
+
try:
|
|
181
|
+
with open("/proc/net/wireless", encoding="utf-8") as f:
|
|
182
|
+
lines = f.readlines()
|
|
183
|
+
except OSError:
|
|
184
|
+
return None, None
|
|
185
|
+
for line in lines[2:]: # skip the two header rows
|
|
186
|
+
if ":" not in line:
|
|
187
|
+
continue
|
|
188
|
+
name, rest = line.split(":", 1)
|
|
189
|
+
name = name.strip()
|
|
190
|
+
if iface not in ("?", "") and name != iface:
|
|
191
|
+
continue
|
|
192
|
+
parts = rest.split()
|
|
193
|
+
if len(parts) < 3:
|
|
194
|
+
continue
|
|
195
|
+
try:
|
|
196
|
+
quality = float(parts[1].rstrip(".")) # link quality (often /70)
|
|
197
|
+
level = float(parts[2].rstrip(".")) # signal level in dBm
|
|
198
|
+
except ValueError:
|
|
199
|
+
continue
|
|
200
|
+
quality_pct = max(0.0, min(100.0, quality / 70.0 * 100.0))
|
|
201
|
+
return level, quality_pct
|
|
202
|
+
return None, None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def signal_label(dbm: float | None) -> str:
|
|
206
|
+
if dbm is None:
|
|
207
|
+
return "n/a"
|
|
208
|
+
if dbm >= -50: return "Excellent"
|
|
209
|
+
if dbm >= -60: return "Good"
|
|
210
|
+
if dbm >= -67: return "Fair"
|
|
211
|
+
if dbm >= -75: return "Weak"
|
|
212
|
+
return "Very weak"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def stability_label(std: float, samples: int) -> str:
|
|
216
|
+
if samples < 3:
|
|
217
|
+
return "โฆ"
|
|
218
|
+
if std < 3.0: return "STABLE"
|
|
219
|
+
if std < 6.0: return "FLUCTUATING"
|
|
220
|
+
return "UNSTABLE"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------- #
|
|
224
|
+
# Environment discovery (best-effort, non-fatal)
|
|
225
|
+
# --------------------------------------------------------------------------- #
|
|
226
|
+
def detect_ssid() -> str:
|
|
227
|
+
for cmd in (["iwgetid", "-r"], ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]):
|
|
228
|
+
try:
|
|
229
|
+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
230
|
+
text=True, timeout=2.0).stdout.strip()
|
|
231
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
232
|
+
continue
|
|
233
|
+
if not out:
|
|
234
|
+
continue
|
|
235
|
+
if cmd[0] == "nmcli":
|
|
236
|
+
for line in out.splitlines():
|
|
237
|
+
if line.startswith("yes:"):
|
|
238
|
+
return line.split(":", 1)[1] or "?"
|
|
239
|
+
continue
|
|
240
|
+
return out
|
|
241
|
+
return "?"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def detect_iface() -> str:
|
|
245
|
+
try:
|
|
246
|
+
out = subprocess.run(["iwgetid"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
247
|
+
text=True, timeout=2.0).stdout
|
|
248
|
+
if out:
|
|
249
|
+
return out.split()[0]
|
|
250
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired, IndexError):
|
|
251
|
+
pass
|
|
252
|
+
# Fall back to the first wireless interface present.
|
|
253
|
+
try:
|
|
254
|
+
with open("/proc/net/wireless", encoding="utf-8") as f:
|
|
255
|
+
for line in f.readlines()[2:]:
|
|
256
|
+
if ":" in line:
|
|
257
|
+
return line.split(":", 1)[0].strip()
|
|
258
|
+
except OSError:
|
|
259
|
+
pass
|
|
260
|
+
return "?"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --------------------------------------------------------------------------- #
|
|
264
|
+
# UI helpers
|
|
265
|
+
# --------------------------------------------------------------------------- #
|
|
266
|
+
class Colors:
|
|
267
|
+
def __init__(self, enabled: bool) -> None:
|
|
268
|
+
self.enabled = enabled
|
|
269
|
+
|
|
270
|
+
def _w(self, code: str, s: str) -> str:
|
|
271
|
+
return f"\033[{code}m{s}\033[0m" if self.enabled else s
|
|
272
|
+
|
|
273
|
+
def green(self, s): return self._w("32", s)
|
|
274
|
+
def red(self, s): return self._w("31", s)
|
|
275
|
+
def yellow(self, s): return self._w("33", s)
|
|
276
|
+
def cyan(self, s): return self._w("36", s)
|
|
277
|
+
def dim(self, s): return self._w("2", s)
|
|
278
|
+
def bold(self, s): return self._w("1", s)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_SPARKS = "โโโโโ
โโโ"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def spark_series(values: list, width: int, miss: str = "ยท") -> str:
|
|
285
|
+
"""Render numeric series as blocks; None entries become `miss`."""
|
|
286
|
+
items = values[-width:]
|
|
287
|
+
nums = [v for v in items if v is not None]
|
|
288
|
+
if not nums:
|
|
289
|
+
return miss * len(items)
|
|
290
|
+
lo, hi = min(nums), max(nums)
|
|
291
|
+
span = (hi - lo) or 1.0
|
|
292
|
+
out = []
|
|
293
|
+
for v in items:
|
|
294
|
+
if v is None:
|
|
295
|
+
out.append(miss)
|
|
296
|
+
else:
|
|
297
|
+
idx = int((v - lo) / span * (len(_SPARKS) - 1))
|
|
298
|
+
out.append(_SPARKS[min(max(idx, 0), len(_SPARKS) - 1)])
|
|
299
|
+
return "".join(out)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fmt_duration(seconds: float) -> str:
|
|
303
|
+
seconds = int(seconds)
|
|
304
|
+
h, rem = divmod(seconds, 3600)
|
|
305
|
+
m, s = divmod(rem, 60)
|
|
306
|
+
return f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def fmt_ms(v: float | None) -> str:
|
|
310
|
+
return f"{v:.1f}" if v is not None else "--"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def fmt_dbm(v: float | None) -> str:
|
|
314
|
+
return f"{v:.0f}" if v is not None else "--"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
HIDE_CURSOR, SHOW_CURSOR, CLEAR_HOME = "\033[?25l", "\033[?25h", "\033[H\033[J"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --------------------------------------------------------------------------- #
|
|
321
|
+
# Interactive input
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
DURATION_CHOICES = [
|
|
324
|
+
("1", "1 minute", 60),
|
|
325
|
+
("2", "10 minutes", 600),
|
|
326
|
+
("3", "1 hour", 3600),
|
|
327
|
+
("4", "Until I close it", None),
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def choose_duration(c: "Colors"):
|
|
332
|
+
"""Prompt the user for how long to run. Returns seconds, or None for forever."""
|
|
333
|
+
if not sys.stdin.isatty():
|
|
334
|
+
return None # non-interactive: run until stopped
|
|
335
|
+
print(c.bold("WiFi Observer") + " โ how long should it run?")
|
|
336
|
+
for key, label, _ in DURATION_CHOICES:
|
|
337
|
+
print(f" [{key}] {label}")
|
|
338
|
+
while True:
|
|
339
|
+
try:
|
|
340
|
+
choice = input(c.cyan("Select 1-4 ") + "(default 4): ").strip() or "4"
|
|
341
|
+
except EOFError:
|
|
342
|
+
return None
|
|
343
|
+
for key, _, dur in DURATION_CHOICES:
|
|
344
|
+
if choice == key:
|
|
345
|
+
return dur
|
|
346
|
+
print(" Please enter 1, 2, 3, or 4.")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class KeyReader:
|
|
350
|
+
"""Context manager for non-blocking single-key reads from the terminal."""
|
|
351
|
+
|
|
352
|
+
def __init__(self) -> None:
|
|
353
|
+
self.enabled = sys.stdin.isatty()
|
|
354
|
+
self._fd = None
|
|
355
|
+
self._old = None
|
|
356
|
+
|
|
357
|
+
def __enter__(self) -> "KeyReader":
|
|
358
|
+
if self.enabled:
|
|
359
|
+
import termios
|
|
360
|
+
import tty
|
|
361
|
+
try:
|
|
362
|
+
self._fd = sys.stdin.fileno()
|
|
363
|
+
self._old = termios.tcgetattr(self._fd)
|
|
364
|
+
tty.setcbreak(self._fd)
|
|
365
|
+
except (termios.error, OSError):
|
|
366
|
+
self.enabled = False
|
|
367
|
+
return self
|
|
368
|
+
|
|
369
|
+
def __exit__(self, *exc) -> None:
|
|
370
|
+
if self._old is not None:
|
|
371
|
+
import termios
|
|
372
|
+
try:
|
|
373
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)
|
|
374
|
+
except (termios.error, OSError):
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
def poll(self, timeout: float):
|
|
378
|
+
"""Wait up to `timeout`s for a keypress; return the char or None."""
|
|
379
|
+
if not self.enabled:
|
|
380
|
+
if timeout > 0:
|
|
381
|
+
time.sleep(timeout)
|
|
382
|
+
return None
|
|
383
|
+
import select
|
|
384
|
+
try:
|
|
385
|
+
ready, _, _ = select.select([sys.stdin], [], [], max(0.0, timeout))
|
|
386
|
+
except (OSError, ValueError):
|
|
387
|
+
return None
|
|
388
|
+
return sys.stdin.read(1) if ready else None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def generate_graph(history, log_path, graph_path):
|
|
392
|
+
"""Render a graph from the full log (or in-memory history). Returns (ok, msg)."""
|
|
393
|
+
import wifi_observer_plot as plot
|
|
394
|
+
if log_path and os.path.isfile(log_path):
|
|
395
|
+
rows = plot.load(log_path) # full session, even beyond memory cap
|
|
396
|
+
else:
|
|
397
|
+
rows = [asdict(p) for p in history]
|
|
398
|
+
if not rows:
|
|
399
|
+
return False, "no data to plot yet"
|
|
400
|
+
os.makedirs(os.path.dirname(graph_path) or ".", exist_ok=True)
|
|
401
|
+
try:
|
|
402
|
+
plot.build_figure(rows, graph_path)
|
|
403
|
+
except ImportError:
|
|
404
|
+
return False, "matplotlib not installed โ run: pip install matplotlib"
|
|
405
|
+
except Exception as exc: # never let plotting crash the monitor
|
|
406
|
+
return False, f"graph error: {exc}"
|
|
407
|
+
return True, f"graph saved โ {graph_path}"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def render(cfg, stats: Stats, history, ssid, iface, started, log_path,
|
|
411
|
+
duration_s, status, c: Colors) -> str:
|
|
412
|
+
now = time.time()
|
|
413
|
+
cols = shutil.get_terminal_size((80, 24)).columns
|
|
414
|
+
inner = max(48, min(cols, 76))
|
|
415
|
+
spark_w = max(20, inner - 14)
|
|
416
|
+
|
|
417
|
+
if duration_s is None:
|
|
418
|
+
time_left = c.dim("mode: until closed")
|
|
419
|
+
else:
|
|
420
|
+
time_left = "remaining: " + fmt_duration(max(0.0, duration_s - (now - started)))
|
|
421
|
+
|
|
422
|
+
# Internet status
|
|
423
|
+
if stats.current_up is None:
|
|
424
|
+
net = c.dim("โฆ starting")
|
|
425
|
+
elif stats.current_up:
|
|
426
|
+
net = c.green("โ UP ")
|
|
427
|
+
else:
|
|
428
|
+
net = c.red("โ DOWN")
|
|
429
|
+
lat = stats.lat_last
|
|
430
|
+
lat_str = c.red("--") if lat is None else (c.yellow(fmt_ms(lat)) if lat > 150 else fmt_ms(lat))
|
|
431
|
+
|
|
432
|
+
# Signal status
|
|
433
|
+
dbm = stats.sig_last
|
|
434
|
+
lab = signal_label(dbm)
|
|
435
|
+
if dbm is None:
|
|
436
|
+
sig_str = c.dim("-- dBm (n/a)")
|
|
437
|
+
elif dbm >= -60:
|
|
438
|
+
sig_str = c.green(f"{fmt_dbm(dbm)} dBm ({lab})")
|
|
439
|
+
elif dbm >= -75:
|
|
440
|
+
sig_str = c.yellow(f"{fmt_dbm(dbm)} dBm ({lab})")
|
|
441
|
+
else:
|
|
442
|
+
sig_str = c.red(f"{fmt_dbm(dbm)} dBm ({lab})")
|
|
443
|
+
|
|
444
|
+
stab = stability_label(stats.sig_std, stats._sig_n)
|
|
445
|
+
stab_str = {"STABLE": c.green, "FLUCTUATING": c.yellow, "UNSTABLE": c.red}.get(
|
|
446
|
+
stab, c.dim)(stab)
|
|
447
|
+
|
|
448
|
+
lat_spark = spark_series([p.latency_ms if p.internet_up else None for p in history], spark_w)
|
|
449
|
+
sig_spark = spark_series([p.signal_dbm for p in history], spark_w)
|
|
450
|
+
if c.enabled:
|
|
451
|
+
lat_spark = "".join(c.red("ยท") if ch == "ยท" else ch for ch in lat_spark)
|
|
452
|
+
|
|
453
|
+
bar = "โ" * (inner - 2)
|
|
454
|
+
lines = [
|
|
455
|
+
c.cyan("โโ WiFi Observer " + bar[15:] + "โ"),
|
|
456
|
+
f" SSID: {c.bold(ssid)} iface: {iface}",
|
|
457
|
+
f" elapsed: {fmt_duration(now - started)} {time_left}",
|
|
458
|
+
c.dim(f" log โ {log_path}"),
|
|
459
|
+
"",
|
|
460
|
+
c.bold(" INTERNET") + f" {net} target {cfg.host}",
|
|
461
|
+
f" latency : last {lat_str} ms avg {fmt_ms(stats.lat_avg)}"
|
|
462
|
+
f" min {fmt_ms(stats.lat_min)} max {fmt_ms(stats.lat_max)} ms",
|
|
463
|
+
f" quality : packet loss {stats.loss_pct:4.1f}% ({stats.down_count}/{stats.total})"
|
|
464
|
+
f" jitter {stats.lat_jitter:.1f} ms",
|
|
465
|
+
f" outages : {stats.outages} downtime {fmt_duration(stats.downtime_s)}"
|
|
466
|
+
f" longest {fmt_duration(stats.longest_outage_s)}",
|
|
467
|
+
"",
|
|
468
|
+
c.bold(" WIFI SIGNAL") + f" {sig_str}",
|
|
469
|
+
f" stability : {stab_str} (ยฑ{stats.sig_std:.1f} dBm)"
|
|
470
|
+
f" min {fmt_dbm(stats.sig_min)} max {fmt_dbm(stats.sig_max)} avg {fmt_dbm(stats.sig_avg)} dBm",
|
|
471
|
+
"",
|
|
472
|
+
f" latency {lat_spark}",
|
|
473
|
+
f" signal {sig_spark}",
|
|
474
|
+
c.cyan("โ" + "โ" * (inner - 2) + "โ"),
|
|
475
|
+
" " + c.bold("[g]") + c.dim(" save graph ") + c.bold("[q]") + c.dim(" quit ")
|
|
476
|
+
+ c.dim("(Ctrl+C also quits)"),
|
|
477
|
+
(" " + c.yellow(status)) if status else "",
|
|
478
|
+
]
|
|
479
|
+
return CLEAR_HOME + "\n".join(lines) + "\n"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def print_summary(stats: Stats, started, log_path, summary_path, graph_msg, c: Colors) -> None:
|
|
483
|
+
now = time.time()
|
|
484
|
+
if c.enabled:
|
|
485
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
486
|
+
print()
|
|
487
|
+
print(c.bold("โโ Session summary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"))
|
|
488
|
+
print(f" duration : {fmt_duration(now - started)}")
|
|
489
|
+
print(" INTERNET")
|
|
490
|
+
print(f" uptime : {stats.uptime_pct:.1f}% packet loss {stats.loss_pct:.1f}% ({stats.up_count}/{stats.total})")
|
|
491
|
+
print(f" latency : avg {fmt_ms(stats.lat_avg)} min {fmt_ms(stats.lat_min)} max {fmt_ms(stats.lat_max)} jitter {stats.lat_jitter:.1f} ms")
|
|
492
|
+
print(f" outages : {stats.outages} downtime {fmt_duration(stats.downtime_s)} longest {fmt_duration(stats.longest_outage_s)}")
|
|
493
|
+
print(" WIFI SIGNAL")
|
|
494
|
+
print(f" signal : avg {fmt_dbm(stats.sig_avg)} min {fmt_dbm(stats.sig_min)} max {fmt_dbm(stats.sig_max)} dBm ({signal_label(stats.sig_avg)})")
|
|
495
|
+
print(f" stability : {stability_label(stats.sig_std, stats._sig_n)} (ยฑ{stats.sig_std:.1f} dBm)")
|
|
496
|
+
print("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
497
|
+
print(c.dim(f" log : {log_path}"))
|
|
498
|
+
print(c.dim(f" summary : {summary_path}"))
|
|
499
|
+
print(c.dim(f" graph : {graph_msg}"))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def write_summary_json(path, stats: Stats, started, cfg, ssid, iface) -> None:
|
|
503
|
+
now = time.time()
|
|
504
|
+
data = {
|
|
505
|
+
"started": dt.datetime.fromtimestamp(started).isoformat(timespec="seconds"),
|
|
506
|
+
"ended": dt.datetime.fromtimestamp(now).isoformat(timespec="seconds"),
|
|
507
|
+
"duration_s": round(now - started, 1),
|
|
508
|
+
"host": cfg.host, "interval_s": cfg.interval, "ssid": ssid, "iface": iface,
|
|
509
|
+
"internet": {
|
|
510
|
+
"samples": stats.total, "up": stats.up_count, "down": stats.down_count,
|
|
511
|
+
"uptime_pct": round(stats.uptime_pct, 2), "packet_loss_pct": round(stats.loss_pct, 2),
|
|
512
|
+
"latency_avg_ms": round(stats.lat_avg, 2) if stats.lat_avg is not None else None,
|
|
513
|
+
"latency_min_ms": stats.lat_min, "latency_max_ms": stats.lat_max,
|
|
514
|
+
"latency_jitter_ms": round(stats.lat_jitter, 2),
|
|
515
|
+
"outages": stats.outages, "downtime_s": round(stats.downtime_s, 1),
|
|
516
|
+
"longest_outage_s": round(stats.longest_outage_s, 1),
|
|
517
|
+
},
|
|
518
|
+
"wifi_signal": {
|
|
519
|
+
"samples": stats._sig_n,
|
|
520
|
+
"avg_dbm": round(stats.sig_avg, 1) if stats.sig_avg is not None else None,
|
|
521
|
+
"min_dbm": stats.sig_min, "max_dbm": stats.sig_max,
|
|
522
|
+
"stddev_dbm": round(stats.sig_std, 2),
|
|
523
|
+
"stability": stability_label(stats.sig_std, stats._sig_n),
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
527
|
+
json.dump(data, f, indent=2)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# --------------------------------------------------------------------------- #
|
|
531
|
+
# Main
|
|
532
|
+
# --------------------------------------------------------------------------- #
|
|
533
|
+
def parse_args(argv=None):
|
|
534
|
+
p = argparse.ArgumentParser(prog="wifi_observer",
|
|
535
|
+
description="Observe internet reachability and WiFi signal stability.")
|
|
536
|
+
p.add_argument("-H", "--host", default="8.8.8.8",
|
|
537
|
+
help="Internet host to ping (default: 8.8.8.8, Google DNS)")
|
|
538
|
+
p.add_argument("-i", "--interval", type=float, default=1.0, help="Seconds between samples (default: 1.0)")
|
|
539
|
+
p.add_argument("-t", "--timeout", type=float, default=1.0, help="Per-ping timeout seconds (default: 1.0)")
|
|
540
|
+
p.add_argument("-n", "--history", type=int, default=3600, help="Samples kept in memory for the UI (default: 3600)")
|
|
541
|
+
p.add_argument("--log", metavar="PATH", default=None,
|
|
542
|
+
help="JSON-lines log path (default: logs/wifi-<timestamp>.jsonl)")
|
|
543
|
+
p.add_argument("--no-log", action="store_true", help="Disable JSON logging")
|
|
544
|
+
p.add_argument("--no-color", action="store_true", help="Disable ANSI colors")
|
|
545
|
+
return p.parse_args(argv)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def main(argv=None) -> int:
|
|
549
|
+
cfg = parse_args(argv)
|
|
550
|
+
color = (not cfg.no_color) and sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
|
|
551
|
+
c = Colors(color)
|
|
552
|
+
|
|
553
|
+
if shutil.which("ping") is None:
|
|
554
|
+
print("error: 'ping' not found in PATH", file=sys.stderr)
|
|
555
|
+
return 1
|
|
556
|
+
|
|
557
|
+
# 1) Interactive startup prompt: how long to run.
|
|
558
|
+
duration_s = choose_duration(c)
|
|
559
|
+
|
|
560
|
+
ssid = detect_ssid()
|
|
561
|
+
iface = detect_iface()
|
|
562
|
+
started = time.time()
|
|
563
|
+
started_dt = dt.datetime.fromtimestamp(started)
|
|
564
|
+
stamp = started_dt.strftime("%H%M%S")
|
|
565
|
+
out_dir = os.path.join("logs", started_dt.strftime("%Y-%m-%d")) # logs/<date>/
|
|
566
|
+
|
|
567
|
+
# Logging + graph output paths, grouped under logs/<date>/.
|
|
568
|
+
log_fh = None
|
|
569
|
+
log_path = summary_path = "(disabled)"
|
|
570
|
+
if not cfg.no_log:
|
|
571
|
+
if cfg.log:
|
|
572
|
+
log_path = cfg.log
|
|
573
|
+
os.makedirs(os.path.dirname(log_path) or ".", exist_ok=True)
|
|
574
|
+
else:
|
|
575
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
576
|
+
log_path = os.path.join(out_dir, f"wifi-{stamp}.jsonl")
|
|
577
|
+
summary_path = os.path.splitext(log_path)[0] + ".summary.json"
|
|
578
|
+
log_fh = open(log_path, "a", buffering=1, encoding="utf-8")
|
|
579
|
+
graph_path = (os.path.splitext(log_path)[0] + ".png") if log_fh is not None \
|
|
580
|
+
else os.path.join(out_dir, f"wifi-{stamp}.png")
|
|
581
|
+
|
|
582
|
+
history = collections.deque(maxlen=cfg.history)
|
|
583
|
+
stats = Stats()
|
|
584
|
+
|
|
585
|
+
stop = {"flag": False}
|
|
586
|
+
signal.signal(signal.SIGINT, lambda *_: stop.__setitem__("flag", True))
|
|
587
|
+
|
|
588
|
+
def draw(status=""):
|
|
589
|
+
sys.stdout.write(render(cfg, stats, history, ssid, iface, started,
|
|
590
|
+
log_path, duration_s, status, c))
|
|
591
|
+
sys.stdout.flush()
|
|
592
|
+
|
|
593
|
+
def time_up():
|
|
594
|
+
return duration_s is not None and (time.time() - started) >= duration_s
|
|
595
|
+
|
|
596
|
+
# 2) Interactive monitor loop with live keys ([g] graph, [q] quit).
|
|
597
|
+
with KeyReader() as keys:
|
|
598
|
+
if color:
|
|
599
|
+
sys.stdout.write(HIDE_CURSOR)
|
|
600
|
+
prev_tick = started
|
|
601
|
+
try:
|
|
602
|
+
while not stop["flag"] and not time_up():
|
|
603
|
+
tick = time.time()
|
|
604
|
+
up, latency = ping_once(cfg.host, cfg.timeout)
|
|
605
|
+
sdbm, squal = read_wifi_signal(iface)
|
|
606
|
+
ts = time.time()
|
|
607
|
+
p = Probe(ts=ts, time=dt.datetime.fromtimestamp(ts).isoformat(timespec="milliseconds"),
|
|
608
|
+
internet_up=up, latency_ms=latency, signal_dbm=sdbm,
|
|
609
|
+
signal_quality=squal, ssid=ssid)
|
|
610
|
+
|
|
611
|
+
if stats.current_up is False: # accumulate downtime per tick while down
|
|
612
|
+
stats.downtime_s += ts - prev_tick
|
|
613
|
+
prev_tick = ts
|
|
614
|
+
|
|
615
|
+
history.append(p)
|
|
616
|
+
stats.add(p)
|
|
617
|
+
if log_fh is not None:
|
|
618
|
+
log_fh.write(json.dumps(asdict(p)) + "\n")
|
|
619
|
+
|
|
620
|
+
draw()
|
|
621
|
+
|
|
622
|
+
# Wait out the interval, staying responsive to keypresses.
|
|
623
|
+
deadline = tick + cfg.interval
|
|
624
|
+
while not stop["flag"] and not time_up():
|
|
625
|
+
wait = deadline - time.time()
|
|
626
|
+
if wait <= 0:
|
|
627
|
+
break
|
|
628
|
+
key = keys.poll(min(0.2, wait))
|
|
629
|
+
if not key:
|
|
630
|
+
continue
|
|
631
|
+
if key in ("q", "Q"):
|
|
632
|
+
stop["flag"] = True
|
|
633
|
+
elif key in ("g", "G"):
|
|
634
|
+
draw(status="generating graphโฆ")
|
|
635
|
+
_, msg = generate_graph(history, log_path, graph_path)
|
|
636
|
+
draw(status=msg)
|
|
637
|
+
finally:
|
|
638
|
+
if color:
|
|
639
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
640
|
+
sys.stdout.flush()
|
|
641
|
+
if log_fh is not None:
|
|
642
|
+
log_fh.close()
|
|
643
|
+
write_summary_json(summary_path, stats, started, cfg, ssid, iface)
|
|
644
|
+
|
|
645
|
+
# 3) Always leave the user with a graph (no need to run plot.py by hand).
|
|
646
|
+
_, graph_msg = generate_graph(history, log_path, graph_path)
|
|
647
|
+
print_summary(stats, started, log_path, summary_path, graph_msg, c)
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
if __name__ == "__main__":
|
|
652
|
+
sys.exit(main())
|
wifi_observer_plot.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wifi_observer_plot.py โ render graphs from WiFi Observer data using matplotlib.
|
|
3
|
+
|
|
4
|
+
The live program (wifi_observer.py) imports `build_figure` from here when you
|
|
5
|
+
press [g], so you normally never run this script by hand. It also works
|
|
6
|
+
standalone:
|
|
7
|
+
|
|
8
|
+
python3 wifi_observer_plot.py [LOGFILE] [-o OUTPUT.png] [--show]
|
|
9
|
+
|
|
10
|
+
If LOGFILE is omitted, the newest logs/wifi-*.jsonl is used. Produces two
|
|
11
|
+
stacked charts sharing a time axis:
|
|
12
|
+
1. Internet latency over time (outages marked in red).
|
|
13
|
+
2. WiFi signal strength (dBm) over time, with quality reference lines.
|
|
14
|
+
|
|
15
|
+
Requires matplotlib: pip install matplotlib
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import datetime as dt
|
|
22
|
+
import glob
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(path: str) -> list[dict]:
|
|
29
|
+
"""Read a JSON-lines log into a list of dicts."""
|
|
30
|
+
rows = []
|
|
31
|
+
with open(path, encoding="utf-8") as f:
|
|
32
|
+
for line in f:
|
|
33
|
+
line = line.strip()
|
|
34
|
+
if line:
|
|
35
|
+
try:
|
|
36
|
+
rows.append(json.loads(line))
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
continue
|
|
39
|
+
return rows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def newest_log() -> str | None:
|
|
43
|
+
# Search both logs/wifi-*.jsonl and logs/<date>/wifi-*.jsonl.
|
|
44
|
+
found = set(glob.glob(os.path.join("logs", "wifi-*.jsonl")))
|
|
45
|
+
found |= set(glob.glob(os.path.join("logs", "**", "wifi-*.jsonl"), recursive=True))
|
|
46
|
+
files = sorted(found, key=os.path.getmtime)
|
|
47
|
+
return files[-1] if files else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_figure(rows: list[dict], out_path: str, show: bool = False) -> str:
|
|
51
|
+
"""Render `rows` to an image at `out_path`. Returns the path.
|
|
52
|
+
|
|
53
|
+
Raises ImportError if matplotlib is unavailable (handled by callers).
|
|
54
|
+
"""
|
|
55
|
+
import matplotlib
|
|
56
|
+
if not show:
|
|
57
|
+
matplotlib.use("Agg") # headless: save without a display
|
|
58
|
+
import matplotlib.pyplot as plt
|
|
59
|
+
import matplotlib.dates as mdates
|
|
60
|
+
|
|
61
|
+
times = [dt.datetime.fromtimestamp(r["ts"]) for r in rows]
|
|
62
|
+
lat = [r.get("latency_ms") if r.get("internet_up") else None for r in rows]
|
|
63
|
+
lat_y = [v if v is not None else float("nan") for v in lat]
|
|
64
|
+
out_t = [t for t, r in zip(times, rows) if not r.get("internet_up")]
|
|
65
|
+
sig = [r.get("signal_dbm") for r in rows]
|
|
66
|
+
sig_t = [t for t, v in zip(times, sig) if v is not None]
|
|
67
|
+
sig_y = [v for v in sig if v is not None]
|
|
68
|
+
ssid = next((r.get("ssid") for r in rows if r.get("ssid")), "?")
|
|
69
|
+
|
|
70
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
|
|
71
|
+
fig.suptitle(f"WiFi Observer โ {ssid} ({len(rows)} samples)", fontweight="bold")
|
|
72
|
+
|
|
73
|
+
# --- Internet latency ---
|
|
74
|
+
ax1.plot(times, lat_y, color="#1f77b4", linewidth=1.2, label="latency (ms)")
|
|
75
|
+
if out_t:
|
|
76
|
+
ymax = max((v for v in lat if v is not None), default=1.0)
|
|
77
|
+
ax1.scatter(out_t, [ymax] * len(out_t), color="red", marker="v", s=28,
|
|
78
|
+
zorder=5, label=f"outage ({len(out_t)})")
|
|
79
|
+
ax1.set_ylabel("Internet latency (ms)")
|
|
80
|
+
ax1.grid(True, alpha=0.3)
|
|
81
|
+
ax1.legend(loc="upper right", fontsize=8)
|
|
82
|
+
|
|
83
|
+
# --- WiFi signal ---
|
|
84
|
+
if sig_y:
|
|
85
|
+
ax2.plot(sig_t, sig_y, color="#2ca02c", linewidth=1.2, label="signal (dBm)")
|
|
86
|
+
for lvl, col in [(-50, "#2ca02c"), (-67, "#ff7f0e"), (-75, "#d62728")]:
|
|
87
|
+
ax2.axhline(lvl, color=col, linestyle="--", linewidth=0.7, alpha=0.5)
|
|
88
|
+
ax2.legend(loc="upper right", fontsize=8)
|
|
89
|
+
else:
|
|
90
|
+
ax2.text(0.5, 0.5, "no WiFi signal data\n(/proc/net/wireless unavailable)",
|
|
91
|
+
ha="center", va="center", transform=ax2.transAxes, color="gray")
|
|
92
|
+
ax2.set_ylabel("WiFi signal (dBm)")
|
|
93
|
+
ax2.set_xlabel("Time")
|
|
94
|
+
ax2.grid(True, alpha=0.3)
|
|
95
|
+
ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
|
|
96
|
+
fig.autofmt_xdate()
|
|
97
|
+
fig.tight_layout(rect=(0, 0, 1, 0.97))
|
|
98
|
+
|
|
99
|
+
fig.savefig(out_path, dpi=120)
|
|
100
|
+
if show:
|
|
101
|
+
plt.show()
|
|
102
|
+
plt.close(fig)
|
|
103
|
+
return out_path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None) -> int:
|
|
107
|
+
ap = argparse.ArgumentParser(description="Plot a WiFi Observer log with matplotlib.")
|
|
108
|
+
ap.add_argument("logfile", nargs="?", help="JSON-lines log (default: newest in logs/)")
|
|
109
|
+
ap.add_argument("-o", "--output", help="Output image path (default: <logfile>.png)")
|
|
110
|
+
ap.add_argument("--show", action="store_true", help="Open an interactive window too")
|
|
111
|
+
args = ap.parse_args(argv)
|
|
112
|
+
|
|
113
|
+
path = args.logfile or newest_log()
|
|
114
|
+
if not path:
|
|
115
|
+
print("error: no log file given and none found in logs/", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
if not os.path.isfile(path):
|
|
118
|
+
print(f"error: log file not found: {path}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
rows = load(path)
|
|
122
|
+
if not rows:
|
|
123
|
+
print(f"error: no samples found in {path}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
out = args.output or (os.path.splitext(path)[0] + ".png")
|
|
127
|
+
try:
|
|
128
|
+
build_figure(rows, out, show=args.show)
|
|
129
|
+
except ImportError:
|
|
130
|
+
print("error: matplotlib is required. Install it with:\n pip install matplotlib",
|
|
131
|
+
file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
print(f"saved graph โ {out}")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|