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.
@@ -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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
41
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)
42
+ [![Platform: Linux](https://img.shields.io/badge/platform-Linux-lightgrey.svg)](#requirements)
43
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)
44
+ [![Made with: Python & Bash](https://img.shields.io/badge/made%20with-Python%20%26%20Bash-1f425f.svg)](#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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wifi-observer = wifi_observer:main
@@ -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.
@@ -0,0 +1,2 @@
1
+ wifi_observer
2
+ wifi_observer_plot
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())