beeping 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .DS_Store
beeping-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergey Pankov
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.
beeping-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: beeping
3
+ Version: 1.0.0
4
+ Summary: Classic ping you can also hear: a high beep on send, a low blip on reply, the gap is the RTT
5
+ Project-URL: Homepage, https://github.com/trapwalker/beeping
6
+ Project-URL: Repository, https://github.com/trapwalker/beeping
7
+ Project-URL: Issues, https://github.com/trapwalker/beeping/issues
8
+ Author: Sergey Pankov
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: audio,beep,icmp,latency,network,ping,sound
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Networking :: Monitoring
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: numpy>=1.24
20
+ Requires-Dist: sounddevice>=0.4.6
21
+ Description-Content-Type: text/markdown
22
+
23
+ # beeping
24
+
25
+ > **English TL;DR:** classic `ping` you can also *hear* — a high «пик» beep on
26
+ > every packet sent, a low «пук» on every reply. The gap between them is your
27
+ > RTT, a lost packet is a «пик» without its «пук». CLI-compatible with the
28
+ > classic `ping`. Run: `uv run beeping.py ya.ru` (or `pipx install beeping`).
29
+
30
+ Классический `ping`, который ещё и **слышно**: каждый отправленный пакет —
31
+ «пик», каждый ответ — «пук». Пауза между ними = RTT, потеря пакета — «пик»
32
+ без «пука». Задержку и её изменения видно на слух.
33
+
34
+ ## Запуск
35
+
36
+ ```bash
37
+ uv run beeping.py [параметры] host # deps подтягиваются из PEP-723 шапки
38
+ uv run beeping.py ya.ru -i 0.5
39
+ ```
40
+
41
+ Зависимости: `numpy`, `sounddevice`. Python ≥ 3.10. Права root не нужны
42
+ (dgram-ICMP): macOS 10.14+ из коробки; на Linux —
43
+ `sysctl -w net.ipv4.ping_group_range="0 2147483647"` либо `sudo`.
44
+
45
+ ## Совместимость с классическим ping
46
+
47
+ CLI совместим с BSD ping на macOS и iputils ping на Linux: getopt-стиль
48
+ (кластеризация `-qc5`, присоединённые значения `-i0.5`), long-опции GNU,
49
+ пер-платформенная семантика `-t`/`-W`/`-A` — как у «родного» ping каждой ОС.
50
+ Вывод в stdout — в формате ping своей платформы (BSD-стиль на macOS,
51
+ iputils-стиль на Linux); служебные сообщения beeping идут в stderr.
52
+
53
+ | Флаг | Значение | Поддержка |
54
+ |---|---|---|
55
+ | `-c N` | стоп после N пакетов (0 = вечно) | ✅ |
56
+ | `-i SEC` | интервал, дробные | ✅ |
57
+ | `-s BYTES` | data bytes, default 56 (как у ping) | ✅ |
58
+ | `-l N` | предзагрузка N пакетов | ✅ |
59
+ | `-f` | flood: до 100/с, точки как у ping | ✅ |
60
+ | `-p HEX` | payload-паттерн (повторяется) | ✅ |
61
+ | `-t` | macOS: общий timeout (BSD) / Linux: TTL (iputils) | ✅ |
62
+ | `--ttl N` | TTL на любой платформе | ✅ |
63
+ | `-w SEC` | deadline | ✅ |
64
+ | `-W` | ожидание ответа: ms на macOS / сек на Linux | ✅ |
65
+ | `-a`, `-A` | BEL в выводе на ответ (macOS `-A` = audible, Linux `-A` = adaptive) | ✅ |
66
+ | `-D` | метки времени | ✅ |
67
+ | `-n`, `-q`, `-v`, `-o` | numeric / тихо / подроб / выход по 1-му ответу | ✅ |
68
+ | `-I iface\|addr`, `-S addr` | привязка к интерфейсу / адресу | ✅ |
69
+ | `-Q tos`, `-r`, `-b`, `--ttl` | TOS / bypass routing / broadcast | ✅ |
70
+ | `-4`, `-6` | IPv4 / IPv6 (ICMPv6) | ✅ |
71
+ | `-h`, `--help` | справка | ✅ |
72
+ | `-R`, `-m`, `-T`, `-L`, `-d`, `-U` | record route, mark, … | ⚠️ берутся, но игнорируются с warning в stderr |
73
+
74
+ Коды выхода, как у iputils ping: `0` — был хоть один ответ, `1` — ответов
75
+ нет, `2` — ошибка запуска (нераспознанный флаг, резолв, сокет). Работает и
76
+ `SIGINFO` (Ctrl+T на macOS): промежуточная статистика в stderr.
77
+
78
+ Кластеризация и long-опции: `ping -qc5 ya.ru` == `beeping -qc5 ya.ru` ==
79
+ `beeping --quiet --count 5 ya.ru`.
80
+
81
+ ## Что не отключается
82
+
83
+ Звук. Это суть beeping: «пи́к»/«пук» всегда, любыми флагами. `-q` глушит только
84
+ текстовую выдачу; звук не пишется в stdout, поэтому парсеры вывода ping
85
+ работают как обычно.
86
+
87
+ ## Примеры
88
+
89
+ ```bash
90
+ uv run beeping.py ya.ru # по умолчанию: -i 1, -s 56
91
+ uv run beeping.py 1.1.1.1 -i 0.3 # джиттер слышнее
92
+ uv run beeping.py -f -c 50 ya.ru # шторм пи́ков на потерях
93
+ uv run beeping.py -6 ::1 # IPv6
94
+ uv run beeping.py ya.ru -v # подробности в stderr
95
+ ```
@@ -0,0 +1,73 @@
1
+ # beeping
2
+
3
+ > **English TL;DR:** classic `ping` you can also *hear* — a high «пик» beep on
4
+ > every packet sent, a low «пук» on every reply. The gap between them is your
5
+ > RTT, a lost packet is a «пик» without its «пук». CLI-compatible with the
6
+ > classic `ping`. Run: `uv run beeping.py ya.ru` (or `pipx install beeping`).
7
+
8
+ Классический `ping`, который ещё и **слышно**: каждый отправленный пакет —
9
+ «пик», каждый ответ — «пук». Пауза между ними = RTT, потеря пакета — «пик»
10
+ без «пука». Задержку и её изменения видно на слух.
11
+
12
+ ## Запуск
13
+
14
+ ```bash
15
+ uv run beeping.py [параметры] host # deps подтягиваются из PEP-723 шапки
16
+ uv run beeping.py ya.ru -i 0.5
17
+ ```
18
+
19
+ Зависимости: `numpy`, `sounddevice`. Python ≥ 3.10. Права root не нужны
20
+ (dgram-ICMP): macOS 10.14+ из коробки; на Linux —
21
+ `sysctl -w net.ipv4.ping_group_range="0 2147483647"` либо `sudo`.
22
+
23
+ ## Совместимость с классическим ping
24
+
25
+ CLI совместим с BSD ping на macOS и iputils ping на Linux: getopt-стиль
26
+ (кластеризация `-qc5`, присоединённые значения `-i0.5`), long-опции GNU,
27
+ пер-платформенная семантика `-t`/`-W`/`-A` — как у «родного» ping каждой ОС.
28
+ Вывод в stdout — в формате ping своей платформы (BSD-стиль на macOS,
29
+ iputils-стиль на Linux); служебные сообщения beeping идут в stderr.
30
+
31
+ | Флаг | Значение | Поддержка |
32
+ |---|---|---|
33
+ | `-c N` | стоп после N пакетов (0 = вечно) | ✅ |
34
+ | `-i SEC` | интервал, дробные | ✅ |
35
+ | `-s BYTES` | data bytes, default 56 (как у ping) | ✅ |
36
+ | `-l N` | предзагрузка N пакетов | ✅ |
37
+ | `-f` | flood: до 100/с, точки как у ping | ✅ |
38
+ | `-p HEX` | payload-паттерн (повторяется) | ✅ |
39
+ | `-t` | macOS: общий timeout (BSD) / Linux: TTL (iputils) | ✅ |
40
+ | `--ttl N` | TTL на любой платформе | ✅ |
41
+ | `-w SEC` | deadline | ✅ |
42
+ | `-W` | ожидание ответа: ms на macOS / сек на Linux | ✅ |
43
+ | `-a`, `-A` | BEL в выводе на ответ (macOS `-A` = audible, Linux `-A` = adaptive) | ✅ |
44
+ | `-D` | метки времени | ✅ |
45
+ | `-n`, `-q`, `-v`, `-o` | numeric / тихо / подроб / выход по 1-му ответу | ✅ |
46
+ | `-I iface\|addr`, `-S addr` | привязка к интерфейсу / адресу | ✅ |
47
+ | `-Q tos`, `-r`, `-b`, `--ttl` | TOS / bypass routing / broadcast | ✅ |
48
+ | `-4`, `-6` | IPv4 / IPv6 (ICMPv6) | ✅ |
49
+ | `-h`, `--help` | справка | ✅ |
50
+ | `-R`, `-m`, `-T`, `-L`, `-d`, `-U` | record route, mark, … | ⚠️ берутся, но игнорируются с warning в stderr |
51
+
52
+ Коды выхода, как у iputils ping: `0` — был хоть один ответ, `1` — ответов
53
+ нет, `2` — ошибка запуска (нераспознанный флаг, резолв, сокет). Работает и
54
+ `SIGINFO` (Ctrl+T на macOS): промежуточная статистика в stderr.
55
+
56
+ Кластеризация и long-опции: `ping -qc5 ya.ru` == `beeping -qc5 ya.ru` ==
57
+ `beeping --quiet --count 5 ya.ru`.
58
+
59
+ ## Что не отключается
60
+
61
+ Звук. Это суть beeping: «пи́к»/«пук» всегда, любыми флагами. `-q` глушит только
62
+ текстовую выдачу; звук не пишется в stdout, поэтому парсеры вывода ping
63
+ работают как обычно.
64
+
65
+ ## Примеры
66
+
67
+ ```bash
68
+ uv run beeping.py ya.ru # по умолчанию: -i 1, -s 56
69
+ uv run beeping.py 1.1.1.1 -i 0.3 # джиттер слышнее
70
+ uv run beeping.py -f -c 50 ya.ru # шторм пи́ков на потерях
71
+ uv run beeping.py -6 ::1 # IPv6
72
+ uv run beeping.py ya.ru -v # подробности в stderr
73
+ ```
@@ -0,0 +1,722 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = ["numpy>=1.24", "sounddevice>=0.4.6"]
5
+ # ///
6
+ """beeping — классический ping, который ещё и слышно.
7
+
8
+ CLI-флаги совместимы с классическим ping: getopt-стиль (кластеризация -qc5,
9
+ присоединённые значения -c5), семантика BSD ping на macOS (iputils на Linux),
10
+ GNU long-опции. Вывод — в формате ping своей платформы. Коды выхода:
11
+ 0 — был хотя бы один ответ, 1 — ответов нет, 2 — ошибка запуска.
12
+
13
+ Туда — «пик», обратно — «пук»; пауза = RTT, потеря = «пик» без «пука».
14
+ Звук в beeping есть всегда — флагами он не отключается (звук не пишется
15
+ в stdout и не мешает парсингу вывода).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import random
21
+ import select
22
+ import signal
23
+ import socket
24
+ import struct
25
+ import sys
26
+ import threading
27
+ import time
28
+ from types import SimpleNamespace
29
+
30
+ try:
31
+ import numpy as np
32
+ import sounddevice as sd
33
+ except ImportError as e: # запущено без uv
34
+ sys.exit(f"не хватает зависимостей ({e}) — запускай через uv: uv run {sys.argv[0]}")
35
+
36
+ SR = 44100
37
+ IS_MAC = sys.platform == "darwin"
38
+
39
+ # --- звук ---------------------------------------------------------------
40
+
41
+
42
+ def tone(f0: float, f1: float, dur: float, vol: float) -> np.ndarray:
43
+ """Синус с глиссандо f0→f1 и короткими фейдами, моно float32."""
44
+ n = int(dur * SR)
45
+ phase = 2.0 * np.pi * np.cumsum(np.linspace(f0, f1, n)) / SR
46
+ env = np.ones(n)
47
+ k = max(1, int(0.006 * SR))
48
+ env[:k] = np.linspace(0.0, 1.0, k)
49
+ env[-k:] = np.linspace(1.0, 0.0, k)
50
+ return (np.sin(phase) * env * vol).astype(np.float32)
51
+
52
+
53
+ # «пик» — короткий высокий тон; «пук» — низкий, съезжающий вниз («бульк»)
54
+ PICK = tone(1568, 1568, 0.050, 0.40)
55
+ PUK = tone(700, 320, 0.110, 0.55)
56
+
57
+
58
+ class Mixer:
59
+ """Одновременное воспроизведение налагающихся тонов без обрезания."""
60
+
61
+ def __init__(self) -> None:
62
+ self._lock = threading.Lock()
63
+ self._pos = 0 # сколько сэмплов уже отдано в колонки
64
+ self._events: list[list] = [] # [start, samples]
65
+ try:
66
+ self.stream = sd.OutputStream(
67
+ samplerate=SR, channels=1, dtype="float32",
68
+ blocksize=0, latency="low", callback=self._cb,
69
+ )
70
+ except OSError as e:
71
+ sys.exit(f"beeping: не удалось открыть аудиовыход: {e}")
72
+ self.stream.start()
73
+
74
+ def play(self, samples: np.ndarray) -> None:
75
+ with self._lock:
76
+ if len(self._events) > 64: # защита от лавины тонов при -f
77
+ del self._events[: len(self._events) - 64]
78
+ self._events.append([self._pos + int(0.015 * SR), samples])
79
+
80
+ def _cb(self, out, frames, t, status) -> None:
81
+ try:
82
+ out[:] = 0.0
83
+ with self._lock:
84
+ base = self._pos
85
+ self._pos += frames
86
+ for start, arr in self._events:
87
+ i0 = max(base - start, 0)
88
+ i1 = min(len(arr), base + frames - start)
89
+ if i1 > i0:
90
+ j0 = start + i0 - base
91
+ j1 = j0 + (i1 - i0)
92
+ out[j0:j1, 0] += arr[i0:i1]
93
+ self._events = [ev for ev in self._events
94
+ if ev[0] + len(ev[1]) > self._pos]
95
+ np.clip(out, -1.0, 1.0, out=out)
96
+ except Exception:
97
+ pass # звук не должен ронять пинг
98
+
99
+
100
+ # --- ICMP / ICMPv6 ------------------------------------------------------
101
+
102
+
103
+ def checksum(data: bytes) -> int:
104
+ if len(data) & 1:
105
+ data += b"\x00"
106
+ s = sum(struct.unpack(f"!{len(data) // 2}H", data))
107
+ s = (s >> 16) + (s & 0xFFFF)
108
+ s += s >> 16
109
+ return ~s & 0xFFFF
110
+
111
+
112
+ def build_echo(fam: str, ident: int, seq: int, payload: bytes) -> bytes:
113
+ """v6: чексумму считает ядро (IPV6_CHECKSUM, offset 2)."""
114
+ if fam == "6":
115
+ return struct.pack("!BBHHH", 128, 0, 0, ident, seq) + payload
116
+ pkt = struct.pack("!BBHHH", 8, 0, 0, ident, seq) + payload
117
+ return struct.pack("!BBHHH", 8, 0, checksum(pkt), ident, seq) + payload
118
+
119
+
120
+ def open_icmp(v6: bool):
121
+ """SOCK_DGRAM ICMP не требует root (macOS; Linux при ping_group_range).
122
+ Возвращает (sock, is_raw): raw — это фоллбэк, там нужен sudo."""
123
+ proto = socket.IPPROTO_ICMPV6 if v6 else socket.IPPROTO_ICMP
124
+ fam = socket.AF_INET6 if v6 else socket.AF_INET
125
+ for t in (socket.SOCK_DGRAM, socket.SOCK_RAW):
126
+ try:
127
+ return socket.socket(fam, t, proto), t == socket.SOCK_RAW
128
+ except OSError:
129
+ continue
130
+ sys.exit(
131
+ "beeping: не удалось открыть ICMP-сокет. Попробуй sudo, а на Linux:\n"
132
+ ' sysctl -w net.ipv4.ping_group_range="0 2147483647"'
133
+ )
134
+
135
+
136
+ def parse_reply(data: bytes):
137
+ """→ (type, code, ident, seq) или None. SOCK_RAW несёт IP-заголовок спереди."""
138
+ if data and data[0] >> 4 == 4:
139
+ data = data[(data[0] & 0x0F) * 4:]
140
+ if len(data) < 8:
141
+ return None
142
+ return data[0], data[1], *struct.unpack("!HH", data[4:8])
143
+
144
+
145
+ def parse_error_ref(data: bytes):
146
+ """id/seq нашего пакета, вложенного в ICMP-ошибку."""
147
+ body = data[8:]
148
+ if len(body) >= 48 and body[0] >> 4 == 6: # внутри исходный IPv6-пакет
149
+ body = body[40:]
150
+ elif body and body[0] >> 4 == 4: # внутри исходный IPv4-пакет
151
+ body = body[(body[0] & 0x0F) * 4:]
152
+ if len(body) >= 8:
153
+ return struct.unpack("!HH", body[4:8])
154
+ return None
155
+
156
+
157
+ ERR_NAMES = {
158
+ "4": {3: "Destination Net Unreachable", 11: "Time to live exceeded"},
159
+ "6": {1: "Destination Unreachable", 2: "Packet Too Big",
160
+ 3: "Time Exceeded", 4: "Parameter Problem"},
161
+ }
162
+ ECHO_REQ = {"4": 8, "6": 128}
163
+ ECHO_REP = {"4": 0, "6": 129}
164
+
165
+ # --- CLI ----------------------------------------------------------------
166
+
167
+
168
+ def usage(code: int) -> None:
169
+ t_desc = "timeout, s (BSD-style)" if IS_MAC else "ttl (iputils-style)"
170
+ w_desc = "waittime, ms (BSD-style)" if IS_MAC else "timeout, s (iputils-style)"
171
+ print(f"""Usage: beeping [options] host
172
+
173
+ Options:
174
+ -c count send count packets, then exit (0 = forever)
175
+ -i interval seconds between sends (fractional ok)
176
+ -s packetsize data bytes, default 56 (like ping)
177
+ -l preload preloaded burst, then normal mode
178
+ -f flood: packets as fast as replies come or 100/s
179
+ -p pattern payload pattern, hex (up to 16 bytes, repeats)
180
+ -t timeout {t_desc}
181
+ -w deadline hard deadline, seconds, then exit
182
+ -W waittime per-reply wait: {w_desc}
183
+ -a (and -A) audible: BEL (\\a) in the output per reply
184
+ -A (Linux) adaptive: interval follows RTT
185
+ -D timestamps on every output line
186
+ -n numeric output, no names
187
+ -o exit after first reply
188
+ -q quiet: startup line + summary only (sound keeps going)
189
+ -v verbose
190
+ -I iface|addr bind to interface (macOS: IP_BOUND_IF) or source address
191
+ -S srcaddr source address
192
+ -Q tos set TOS/DSCP (int or 0x..)
193
+ -r bypass routing tables
194
+ -b allow pinging broadcast
195
+ --ttl n IP TTL (any platform)
196
+ -4 / -6 force IPv4 / IPv6
197
+ -h --help this text
198
+
199
+ Sound (the whole point of beeping): every send = «пик», every reply = «пук».
200
+ The gap between them is the RTT, losses are «пик» with no «пук».""")
201
+ sys.exit(code)
202
+
203
+
204
+ def parse_args(argv: list[str]) -> SimpleNamespace:
205
+ cfg = SimpleNamespace(
206
+ host=None, count=0, interval=1.0, size=56, preload=0,
207
+ deadline=None, wait=None, flood=False, numeric=False, quiet=False,
208
+ verbose=False, bel=False, adaptive=False, timestamps=False,
209
+ onefire=False, broadcast=False, dontroute=False, report_out=False,
210
+ pattern_bytes=None, source=None, iface_name=None,
211
+ tos=None, ttl=None, family=socket.AF_UNSPEC,
212
+ )
213
+ warns: list[str] = []
214
+
215
+ def die(msg: str, code: int = 2) -> None:
216
+ print(f"beeping: {msg}", file=sys.stderr)
217
+ sys.exit(code)
218
+
219
+ def _int(v: str, flag: str) -> int:
220
+ try:
221
+ return int(v, 0)
222
+ except ValueError:
223
+ die(f"invalid value for {flag}: {v!r}")
224
+
225
+ def _float(v: str, flag: str) -> float:
226
+ try:
227
+ return float(v)
228
+ except ValueError:
229
+ die(f"invalid value for {flag}: {v!r}")
230
+
231
+ def apply_value(ch: str, v: str) -> None:
232
+ if ch == "c":
233
+ cfg.count = _int(v, "-c")
234
+ if cfg.count < 0:
235
+ die("count must be >= 0")
236
+ elif ch == "i":
237
+ cfg.interval = _float(v, "-i")
238
+ elif ch == "s":
239
+ cfg.size = _int(v, "-s")
240
+ elif ch == "l":
241
+ cfg.preload = _int(v, "-l")
242
+ elif ch == "t":
243
+ if IS_MAC:
244
+ cfg.deadline = _float(v, "-t")
245
+ else:
246
+ cfg.ttl = _int(v, "-t")
247
+ elif ch == "w":
248
+ cfg.deadline = _float(v, "-w")
249
+ elif ch == "W":
250
+ cfg.wait = _float(v, "-W") / 1000.0 if IS_MAC else _float(v, "-W")
251
+ elif ch == "I":
252
+ cfg.iface_raw = v
253
+ elif ch == "S":
254
+ cfg.source = v
255
+ elif ch == "p":
256
+ if len(v) % 2 or any(c not in "0123456789abcdefABCDEF" for c in v):
257
+ die("patterns must be specified as even-length hex digits")
258
+ cfg.pattern_bytes = bytes.fromhex(v) or b"\x00"
259
+ elif ch == "Q":
260
+ cfg.tos = _int(v, "-Q")
261
+ else: # T, m, M — известные, но не поддержанные
262
+ warns.append(f"flag -{ch} is not supported, ignored")
263
+
264
+ def apply_bool(ch: str) -> None:
265
+ if ch == "a":
266
+ cfg.bel = True
267
+ elif ch == "A":
268
+ if IS_MAC:
269
+ cfg.bel = True # BSD-style: -A audible
270
+ else:
271
+ cfg.adaptive = True # iputils-style: -A adaptive
272
+ elif ch == "D":
273
+ cfg.timestamps = True
274
+ elif ch == "f":
275
+ cfg.flood = True
276
+ elif ch == "n":
277
+ cfg.numeric = True
278
+ elif ch == "q":
279
+ cfg.quiet = True
280
+ elif ch == "v":
281
+ cfg.verbose = True
282
+ elif ch == "r":
283
+ cfg.dontroute = True
284
+ elif ch == "b":
285
+ cfg.broadcast = True
286
+ elif ch == "o":
287
+ cfg.onefire = True
288
+ elif ch == "O":
289
+ cfg.report_out = True
290
+ elif ch == "4":
291
+ cfg.family = socket.AF_INET
292
+ elif ch == "6":
293
+ cfg.family = socket.AF_INET6
294
+ else: # R, L, d, U — известные, но не поддержанные
295
+ warns.append(f"flag -{ch} is not supported, ignored")
296
+
297
+ LONG_VALUE = {"count": "c", "interval": "i", "size": "s", "packetsize": "s",
298
+ "preload": "l", "deadline": "w", "timeout": "t", "wait": "W",
299
+ "waittime": "W", "interface": "I", "source": "S",
300
+ "pattern": "p", "tos": "Q"}
301
+ LONG_BOOL = {"flood": "f", "numeric": "n", "quiet": "q", "verbose": "v",
302
+ "audible": "a", "timestamps": "D", "broadcast": "b",
303
+ "dontroute": "r", "one-reply": "o", "outstanding": "O",
304
+ "adaptive": "A", "ipv4": "4", "ipv6": "6"}
305
+
306
+ hosts: list[str] = []
307
+ VALUE_SHORT = "cisltwWIpSQTmM"
308
+ BOOL_SHORT = "aAbDfnqrvbo46OLRdU"
309
+ i = 0
310
+ args = list(argv)
311
+ while i < len(args):
312
+ a = args[i]
313
+ if a == "--":
314
+ hosts.extend(args[i + 1:])
315
+ break
316
+ if a.startswith("--"):
317
+ key, _, val = a[2:].partition("=")
318
+ if key == "help":
319
+ usage(0)
320
+ if key == "ttl":
321
+ if not val:
322
+ i += 1
323
+ if i >= len(args):
324
+ die("--ttl requires an argument")
325
+ val = args[i]
326
+ cfg.ttl = _int(val, "--ttl")
327
+ elif key in LONG_VALUE:
328
+ if not val:
329
+ i += 1
330
+ if i >= len(args):
331
+ die(f"--{key} requires an argument")
332
+ val = args[i]
333
+ apply_value(LONG_VALUE[key], val)
334
+ elif key in LONG_BOOL:
335
+ apply_bool(LONG_BOOL[key])
336
+ else:
337
+ die(f"unrecognized option --{key} (try -h)")
338
+ i += 1
339
+ continue
340
+ if len(a) > 1 and a[0] == "-":
341
+ body = a[1:]
342
+ j = 0
343
+ while j < len(body):
344
+ ch = body[j]
345
+ if ch == "h":
346
+ usage(0)
347
+ if ch in VALUE_SHORT:
348
+ v = body[j + 1:]
349
+ if not v:
350
+ i += 1
351
+ if i >= len(args):
352
+ die(f"option -{ch} requires an argument")
353
+ v = args[i]
354
+ apply_value(ch, v)
355
+ j = len(body)
356
+ elif ch in BOOL_SHORT:
357
+ apply_bool(ch)
358
+ j += 1
359
+ else:
360
+ die(f"invalid option -- '{ch}' (try -h)")
361
+ i += 1
362
+ continue
363
+ hosts.append(a)
364
+ i += 1
365
+
366
+ if len(hosts) == 0:
367
+ usage(2)
368
+ if len(hosts) > 1:
369
+ die("only one destination allowed")
370
+ cfg.host = hosts[0]
371
+
372
+ if cfg.family == socket.AF_UNSPEC:
373
+ cfg.family = socket.AF_INET6 if ":" in cfg.host else socket.AF_INET
374
+
375
+ if cfg.size < 0 or cfg.size > 65527:
376
+ die(f"invalid packetsize {cfg.size}")
377
+ if cfg.preload < 0:
378
+ die("preload must be >= 0")
379
+ if cfg.wait is not None and cfg.wait <= 0:
380
+ die("waittime must be positive")
381
+ if cfg.deadline is not None and cfg.deadline <= 0:
382
+ die("deadline must be positive")
383
+ if not cfg.flood and cfg.interval < 0.01:
384
+ die("interval below 10 ms needs -f")
385
+ if cfg.count and cfg.preload > cfg.count:
386
+ cfg.preload = cfg.count
387
+ cfg.iface_raw = getattr(cfg, "iface_raw", None)
388
+ if cfg.iface_raw:
389
+ if ":" in cfg.iface_raw:
390
+ cfg.source = cfg.iface_raw # IPv6-литерал
391
+ else:
392
+ try:
393
+ socket.inet_aton(cfg.iface_raw)
394
+ cfg.source = cfg.iface_raw # IPv4-литерал
395
+ except OSError:
396
+ cfg.iface_name = cfg.iface_raw
397
+ for w in warns:
398
+ print(f"beeping: {w}", file=sys.stderr)
399
+ return cfg
400
+
401
+
402
+ # --- розетка ------------------------------------------------------------
403
+
404
+
405
+ def build_socket(cfg: SimpleNamespace, v6: bool):
406
+ warns: list[str] = []
407
+ sock, is_raw = open_icmp(v6)
408
+
409
+ def so(level, opt, val, what):
410
+ if opt is None:
411
+ warns.append(f"{what}: not supported on this platform")
412
+ return
413
+ try:
414
+ sock.setsockopt(level, opt, val)
415
+ except OSError as e:
416
+ warns.append(f"{what}: {e}")
417
+
418
+ if cfg.iface_name:
419
+ if v6:
420
+ so(socket.IPPROTO_IPV6, getattr(socket, "IPV6_BOUND_IF", 125),
421
+ socket.if_nametoindex(cfg.iface_name), f"-I {cfg.iface_name}")
422
+ else:
423
+ so(socket.IPPROTO_IP, getattr(socket, "IP_BOUND_IF", 25),
424
+ socket.if_nametoindex(cfg.iface_name), f"-I {cfg.iface_name}")
425
+ if cfg.source:
426
+ try:
427
+ sock.bind((cfg.source, 0))
428
+ except OSError as e:
429
+ warns.append(f"bind {cfg.source}: {e}")
430
+ if cfg.broadcast:
431
+ so(socket.SOL_SOCKET, socket.SO_BROADCAST, 1, "-b")
432
+ if cfg.dontroute:
433
+ so(socket.SOL_SOCKET, socket.SO_DONTROUTE, 1, "-r")
434
+ if v6:
435
+ if is_raw: # на dgram-сокетах чексумму считает ядро (darwin: EPERM)
436
+ so(socket.IPPROTO_ICMPV6, getattr(socket, "IPV6_CHECKSUM", 26), 2, "checksum")
437
+ if cfg.tos is not None:
438
+ so(socket.IPPROTO_IPV6, getattr(socket, "IPV6_TCLASS", None), cfg.tos, "-Q")
439
+ if cfg.ttl is not None:
440
+ so(socket.IPPROTO_IPV6, getattr(socket, "IPV6_UNICAST_HOPS", None),
441
+ cfg.ttl, "ttl")
442
+ hop_opts = [getattr(socket, "IPV6_RECVHOPLIMIT", None)]
443
+ if IS_MAC:
444
+ hop_opts += [37, 20] # darwin: RFC3542 и RFC2292 версии
445
+ set_hlim = False
446
+ for opt in hop_opts:
447
+ if opt is None:
448
+ continue
449
+ try:
450
+ sock.setsockopt(socket.IPPROTO_IPV6, opt, 1)
451
+ set_hlim = True
452
+ break
453
+ except OSError:
454
+ continue
455
+ if not set_hlim:
456
+ warns.append("hlim recv: not supported on this platform")
457
+ else:
458
+ if cfg.tos is not None:
459
+ so(socket.IPPROTO_IP, socket.IP_TOS, cfg.tos, "-Q")
460
+ if cfg.ttl is not None:
461
+ so(socket.IPPROTO_IP, socket.IP_TTL, cfg.ttl, "ttl")
462
+ so(socket.IPPROTO_IP, getattr(socket, "IP_RECVTTL", 24 if IS_MAC else None),
463
+ 1, "ttl recv")
464
+ sock.setblocking(False)
465
+ return sock, warns
466
+
467
+
468
+ def anc_ttl(anc: list) -> int | None:
469
+ """TTL/hlim из ancillary: на linux — 4-байтовый int, на darwin — один
470
+ байт. Уровень IP/IPv6 + короткий буфер однозначно указывают на TTL,
471
+ т.к. других cmsg мы не просим."""
472
+ for level, _typ, val in anc:
473
+ if level not in (socket.IPPROTO_IP, socket.IPPROTO_IPV6):
474
+ continue
475
+ if len(val) == 4:
476
+ try:
477
+ return struct.unpack("i", val)[0]
478
+ except struct.error:
479
+ pass
480
+ if len(val) == 1:
481
+ return val[0]
482
+ return None
483
+
484
+
485
+ # --- main ---------------------------------------------------------------
486
+
487
+
488
+ def main() -> None:
489
+ cfg = parse_args(sys.argv[1:])
490
+ v6 = cfg.family == socket.AF_INET6
491
+ fam = "6" if v6 else "4"
492
+ try:
493
+ sockaddr = socket.getaddrinfo(cfg.host, None, cfg.family)[0][4]
494
+ except OSError as e:
495
+ print(f"beeping: cannot resolve {cfg.host!r}: {e}", file=sys.stderr)
496
+ sys.exit(2)
497
+ ip = sockaddr[0].split("%")[0]
498
+
499
+ sock, warns = build_socket(cfg, v6)
500
+ for w in warns:
501
+ print(f"beeping: {w}", file=sys.stderr)
502
+
503
+ ident = random.randrange(1, 0x10000)
504
+ mixer = Mixer()
505
+
506
+ sent = received = 0
507
+ rtts: list[float] = []
508
+ pending: dict[int, list] = {} # seq → [t0(perf_counter), timer(monotonic)|None]
509
+ expired: dict[int, float] = {} # seq → t0 (снятые по -W; поздние ответы считаем)
510
+ seq_next = 0
511
+ preload_left = cfg.preload
512
+ t_start = time.monotonic()
513
+ deadline_at = t_start + cfg.deadline if cfg.deadline is not None else None
514
+ next_send = t_start
515
+ done_sending = False
516
+ done_at = None
517
+ stop = False
518
+
519
+ if IS_MAC: # классический BSD-заголовок
520
+ print(f"PING {cfg.host} ({ip}): {cfg.size} data bytes", flush=True)
521
+ else: # iputils-заголовок
522
+ print(f"PING {cfg.host} ({ip}) {cfg.size}({cfg.size + 28}) bytes of data.",
523
+ flush=True)
524
+ print("beeping: «пик» туда, «пук» обратно; пауза = задержка. Ctrl+C — итоги.",
525
+ file=sys.stderr, flush=True)
526
+
527
+ if hasattr(signal, "SIGINFO"): # Ctrl+T на macOS: промежуточная статистика
528
+ def _info(_sig, _frm):
529
+ print(f"beeping: {sent} sent, {received} received, "
530
+ f"{len(pending)} in flight", file=sys.stderr, flush=True)
531
+ try:
532
+ signal.signal(signal.SIGINFO, _info)
533
+ except Exception:
534
+ pass
535
+
536
+ def make_payload() -> bytes:
537
+ pat = cfg.pattern_bytes or b"\x00"
538
+ if cfg.size >= 8: # как iputils: первые 8 байт — время отправки
539
+ n = cfg.size - 8
540
+ return struct.pack("!Q", time.perf_counter_ns()) + (pat * (n // len(pat) + 1))[:n]
541
+ return (pat * (cfg.size // len(pat) + 1))[:cfg.size]
542
+
543
+ def send_probe() -> None:
544
+ nonlocal sent, seq_next
545
+ try:
546
+ sock.sendto(build_echo(fam, ident, seq_next, make_payload()), sockaddr)
547
+ except OSError as e:
548
+ print(f"beeping: sendto: {e}", file=sys.stderr, flush=True)
549
+ return
550
+ sent += 1
551
+ now2 = time.monotonic()
552
+ if cfg.wait is not None:
553
+ timer = now2 + cfg.wait
554
+ elif cfg.report_out:
555
+ timer = now2 + max(0.05, cfg.interval)
556
+ else:
557
+ timer = None
558
+ pending[seq_next] = [time.perf_counter(), timer]
559
+ if cfg.flood and not cfg.quiet:
560
+ print(".", end="", flush=True)
561
+ seq_next = (seq_next + 1) & 0xFFFF
562
+
563
+ def handle(data: bytes, anc: list, src) -> None:
564
+ nonlocal received, stop
565
+ msg = parse_reply(data)
566
+ if msg is None:
567
+ return
568
+ mtype, mcode, mid, mseq = msg
569
+ src_ip = src[0].split("%")[0]
570
+ if mid != ident or src_ip != ip:
571
+ if cfg.verbose:
572
+ print(f"beeping: foreign ICMP type={mtype} code={mcode} from {src_ip}",
573
+ file=sys.stderr, flush=True)
574
+ return
575
+ if mtype == ECHO_REP[fam]: # эхо-ответ
576
+ e = pending.pop(mseq, None)
577
+ t0 = e[0] if e is not None else expired.get(mseq)
578
+ if t0 is None:
579
+ if cfg.verbose:
580
+ print(f"beeping: unexpected reply seq={mseq}", file=sys.stderr, flush=True)
581
+ return
582
+ rtt_ms = (time.perf_counter() - t0) * 1000.0
583
+ received += 1
584
+ rtts.append(rtt_ms)
585
+ mixer.play(PUK)
586
+ if cfg.wait is None or rtt_ms <= cfg.wait * 1000.0:
587
+ ttl = anc_ttl(anc)
588
+ if cfg.flood:
589
+ if not cfg.quiet:
590
+ print("\b" + ("\a" if cfg.bel else ""), end="", flush=True)
591
+ else:
592
+ line = f"{cfg.size} bytes from {ip}: icmp_seq={mseq}"
593
+ if ttl is not None:
594
+ label = "hlim" if v6 else "ttl"
595
+ line += f" {label}={ttl}"
596
+ line += f" time={rtt_ms:.3f} ms" if IS_MAC else f" time={rtt_ms:.1f} ms"
597
+ if cfg.timestamps:
598
+ line = f"[{time.time():.3f}] " + line
599
+ if cfg.bel:
600
+ line += "\a"
601
+ if not cfg.quiet:
602
+ print(line, flush=True)
603
+ elif cfg.verbose:
604
+ print(f"beeping: late reply seq={mseq} (counted, not printed)",
605
+ file=sys.stderr, flush=True)
606
+ if cfg.onefire:
607
+ stop = True
608
+ elif mtype in ERR_NAMES[fam]:
609
+ ref = parse_error_ref(data)
610
+ if ref and ref[0] == ident and ref[1] in pending:
611
+ pending.pop(ref[1])
612
+ if not cfg.quiet:
613
+ print(f"From {ip}: icmp_seq={ref[1]} "
614
+ f"{ERR_NAMES[fam][mtype]}", flush=True)
615
+ elif cfg.verbose:
616
+ print(f"beeping: ICMP type={mtype} code={mcode} from {ip}",
617
+ file=sys.stderr, flush=True)
618
+
619
+ def drain() -> None:
620
+ while True:
621
+ try:
622
+ data, anc, _flags, src = sock.recvmsg(2048, 512)
623
+ except (BlockingIOError, InterruptedError):
624
+ break
625
+ except OSError:
626
+ break
627
+ handle(data, anc, src)
628
+
629
+ def reap(now2: float) -> None:
630
+ if cfg.wait is not None:
631
+ for sq in [s for s, e in pending.items()
632
+ if e[1] is not None and now2 >= e[1]]:
633
+ # BSD-семантика: молча уходит в потери, поздний ответ учтётся
634
+ expired[sq] = pending.pop(sq)[0]
635
+ elif cfg.report_out:
636
+ for sq, e in list(pending.items()):
637
+ if e[1] is not None and now2 >= e[1]:
638
+ print(f"no answer yet for icmp_seq={sq}", flush=True)
639
+ e[1] = now2 + max(0.05, cfg.interval)
640
+
641
+ try:
642
+ while True:
643
+ now = time.monotonic()
644
+ if done_sending:
645
+ timeout = 0.05
646
+ elif cfg.flood:
647
+ timeout = max(0.0, min(next_send - now, 0.01))
648
+ else:
649
+ timeout = max(0.0, next_send - now)
650
+ if pending and (cfg.wait is not None or cfg.report_out):
651
+ nd = min(e[1] for e in pending.values())
652
+ timeout = min(timeout, max(0.0, nd - now))
653
+ ready, _, _ = select.select([sock], [], [], timeout)
654
+ if ready:
655
+ drain()
656
+ if stop:
657
+ break
658
+ now = time.monotonic()
659
+ reap(now)
660
+ if deadline_at is not None and now >= deadline_at:
661
+ break # как ping -t: выходим сразу, недождённые — потери
662
+ if not done_sending:
663
+ if cfg.flood:
664
+ want = (preload_left > 0 or not pending
665
+ or now >= next_send) and len(pending) < 100
666
+ else:
667
+ want = preload_left > 0 or now >= next_send
668
+ if want and (cfg.count == 0 or sent < cfg.count):
669
+ send_probe()
670
+ if preload_left > 0:
671
+ preload_left -= 1
672
+ if preload_left == 0:
673
+ next_send = now + cfg.interval
674
+ elif cfg.flood:
675
+ next_send = now + 0.01
676
+ else:
677
+ next_send = max(next_send + cfg.interval, now + 0.02)
678
+ if cfg.count and sent >= cfg.count:
679
+ done_sending = True
680
+ done_at = now + max(1.0, cfg.interval)
681
+ elif not pending or now >= done_at:
682
+ break
683
+ except KeyboardInterrupt:
684
+ pass
685
+ finally:
686
+ try:
687
+ mixer.stream.stop(); mixer.stream.close()
688
+ except Exception:
689
+ pass
690
+ try:
691
+ sock.close()
692
+ except Exception:
693
+ pass
694
+ if cfg.flood and not cfg.quiet:
695
+ print(flush=True)
696
+ elapsed = (time.monotonic() - t_start) * 1000
697
+ lost = sent - received
698
+ if IS_MAC:
699
+ print(f"\n--- {cfg.host} ping statistics ---")
700
+ print(f"{sent} packets transmitted, {received} packets received, "
701
+ f"{lost / sent * 100:.1f}% packet loss" if sent else
702
+ f"0 packets transmitted, 0 packets received, 100.0% packet loss")
703
+ if rtts:
704
+ mn, av, mx = min(rtts), sum(rtts) / len(rtts), max(rtts)
705
+ var = sum((r - av) ** 2 for r in rtts) / len(rtts)
706
+ print(f"round-trip min/avg/max/stddev = "
707
+ f"{mn:.3f}/{av:.3f}/{mx:.3f}/{var ** 0.5:.3f} ms")
708
+ else:
709
+ print(f"\n--- {cfg.host} ping statistics ---")
710
+ print(f"{sent} packets transmitted, {received} received, "
711
+ f"{lost / sent * 100:g}% packet loss, time {elapsed:.0f}ms"
712
+ if sent else "0 packets transmitted, 0 received, 100% packet loss")
713
+ if rtts:
714
+ mn, av, mx = min(rtts), sum(rtts) / len(rtts), max(rtts)
715
+ mdev = sum(abs(r - av) for r in rtts) / len(rtts)
716
+ print(f"rtt min/avg/max/mdev = "
717
+ f"{mn:.3f}/{av:.3f}/{mx:.3f}/{mdev:.3f} ms")
718
+ sys.exit(0 if received else 1)
719
+
720
+
721
+ if __name__ == "__main__":
722
+ main()
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "beeping"
7
+ version = "1.0.0"
8
+ description = "Classic ping you can also hear: a high beep on send, a low blip on reply, the gap is the RTT"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Sergey Pankov" }]
13
+ keywords = ["ping", "icmp", "audio", "sound", "beep", "network", "latency"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Intended Audience :: System Administrators",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Networking :: Monitoring",
21
+ ]
22
+ dependencies = ["numpy>=1.24", "sounddevice>=0.4.6"]
23
+
24
+ [project.scripts]
25
+ beeping = "beeping:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/trapwalker/beeping"
29
+ Repository = "https://github.com/trapwalker/beeping"
30
+ Issues = "https://github.com/trapwalker/beeping/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ only-include = ["beeping.py"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ only-include = ["beeping.py", "README.md", "LICENSE", "pyproject.toml"]