mobs-ws 0.16.1__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.
mobs_ws/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Official client for MOBS /v1/ws/metar (protocol 4)."""
2
+
3
+ from mobs_ws.client import (
4
+ BACKOFF_CAP_S,
5
+ BACKOFF_START_S,
6
+ DEFAULT_URL,
7
+ PING_EVERY_S,
8
+ PONG_TIMEOUT_S,
9
+ QUIET_OPEN_S,
10
+ MetarClient,
11
+ MetarExample,
12
+ apply_awc_confirmed,
13
+ fetch_latest,
14
+ handshake_url,
15
+ latest_url,
16
+ merge_record,
17
+ next_backoff,
18
+ resolve_token,
19
+ should_retry,
20
+ )
21
+
22
+ __all__ = [
23
+ "BACKOFF_CAP_S",
24
+ "BACKOFF_START_S",
25
+ "DEFAULT_URL",
26
+ "PING_EVERY_S",
27
+ "PONG_TIMEOUT_S",
28
+ "QUIET_OPEN_S",
29
+ "MetarClient",
30
+ "MetarExample",
31
+ "apply_awc_confirmed",
32
+ "fetch_latest",
33
+ "handshake_url",
34
+ "latest_url",
35
+ "merge_record",
36
+ "next_backoff",
37
+ "resolve_token",
38
+ "should_retry",
39
+ ]
40
+
41
+ __version__ = "0.16.1"
mobs_ws/__main__.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import sys
7
+
8
+ from mobs_ws.client import DEFAULT_URL, MetarClient, resolve_token
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="MOBS /v1/ws/metar official client")
13
+ parser.add_argument("--url", default=os.environ.get("MOBS_WS_URL", DEFAULT_URL))
14
+ parser.add_argument("--airports", default="KNYC,KLGA")
15
+ parser.add_argument("--token", default=None)
16
+ args = parser.parse_args()
17
+ try:
18
+ token = resolve_token(args.token)
19
+ except ValueError:
20
+ sys.exit("set MOBS_TOKEN or pass --token; use the placeholder YOUR_API_KEY in examples only")
21
+ airports = [part.strip() for part in args.airports.split(",") if part.strip()]
22
+ client = MetarClient(airports, token=token, url=args.url)
23
+ asyncio.run(client.run_forever())
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
mobs_ws/client.py ADDED
@@ -0,0 +1,368 @@
1
+ """Official client for MOBS /v1/ws/metar (protocol 4).
2
+
3
+ Promoted from examples/ws_client.py. Uses the ``websockets`` package
4
+ only. HTTP backfill uses urllib from the standard library.
5
+
6
+ Features: JSON ping every 20 s, 15 s pong timeout, exponential backoff
7
+ with jitter (reset after the first message or about 10 s of an open
8
+ socket; the 10 s timer starts when the socket opens), opt-in snapshot
9
+ and resume with since_seq and boot_id, GET /v1/metar/latest on a resume
10
+ fallback (boot_id change, partial, or none), dedupe on icao+versions
11
+ plus icao+obs_time+raw_metar, awc_confirmed last-wins per
12
+ (icao, versions), station_late / station_recovered / usage
13
+ handled (seq advances; usage is a point-in-time read), and
14
+ unknown frame types ignored. Filter and delta are opt-in on a
15
+ mid-session subscribe, not on the handshake query.
16
+
17
+ The token comes only from the caller or the MOBS_TOKEN environment
18
+ variable. It is never embedded.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import os
25
+ import random
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from collections.abc import Callable
31
+ from typing import Any
32
+
33
+ from websockets.asyncio.client import connect
34
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
35
+
36
+ DEFAULT_URL = "wss://mobs.ws/v1/ws/metar"
37
+ PING_EVERY_S = 20.0
38
+ PONG_TIMEOUT_S = 15.0
39
+ BACKOFF_START_S = 1.0
40
+ BACKOFF_CAP_S = 30.0
41
+ QUIET_OPEN_S = 10.0
42
+
43
+
44
+ def resolve_token(token: str | None = None) -> str:
45
+ """Return the caller-supplied token, or MOBS_TOKEN from the environment."""
46
+ value = token if token not in (None, "") else os.environ.get("MOBS_TOKEN") or ""
47
+ if not value:
48
+ raise ValueError("pass a token or set MOBS_TOKEN")
49
+ return value
50
+
51
+
52
+ def next_backoff(delay: float, *, rand: Callable[[], float] = random.random) -> float:
53
+ """Double, cap at about 30 s, then apply jitter (about 15-45 s at the cap)."""
54
+ grown = min(delay * 2.0, BACKOFF_CAP_S)
55
+ return grown * (0.5 + rand())
56
+
57
+
58
+ def should_retry(code: int | None) -> bool:
59
+ return code != 4403
60
+
61
+
62
+ def _invalid_status_code(exc: InvalidStatus) -> int | None:
63
+ response = getattr(exc, "response", None)
64
+ return getattr(response, "status_code", None)
65
+
66
+
67
+ def connect_failure_code(exc: BaseException) -> int:
68
+ """Map a failed connect to a close code: 401/403 -> 4403 (stop), else 1006."""
69
+ if isinstance(exc, InvalidStatus) and _invalid_status_code(exc) in {401, 403}:
70
+ return 4403
71
+ return 1006
72
+
73
+
74
+ def newer_than(incoming: dict, held: dict) -> bool:
75
+ """Live (or any newer versions / later body) wins over a cached or older record."""
76
+ if incoming.get("versions") != held.get("versions"):
77
+ return True
78
+ if (incoming.get("icao"), incoming.get("obs_time"), incoming.get("raw_metar")) != (
79
+ held.get("icao"),
80
+ held.get("obs_time"),
81
+ held.get("raw_metar"),
82
+ ):
83
+ return True
84
+ return incoming.get("_live", False) and not held.get("_live", False)
85
+
86
+
87
+ def merge_record(latest: dict[str, dict], record: dict, *, live: bool = False) -> dict:
88
+ """Dedupe on icao+versions; icao+obs_time+raw_metar is the body identity."""
89
+ item = dict(record)
90
+ item["_live"] = live
91
+ icao = item.get("icao")
92
+ if not icao:
93
+ return latest
94
+ held = latest.get(icao)
95
+ if held is None or newer_than(item, held):
96
+ latest[icao] = item
97
+ return latest
98
+
99
+
100
+ def apply_awc_confirmed(latest: dict[str, dict], message: dict) -> dict:
101
+ """Last-wins per (icao, versions). Ignore a versions the client never saw."""
102
+ icao = message.get("icao")
103
+ versions = message.get("versions")
104
+ if not icao or versions is None:
105
+ return latest
106
+ held = latest.get(icao)
107
+ if held is None or held.get("versions") != versions:
108
+ return latest
109
+ held["awc_confirmed"] = True
110
+ held["awc_confirmed_at"] = message.get("awc_confirmed_at")
111
+ return latest
112
+
113
+
114
+ def handshake_url(
115
+ base: str,
116
+ airports: list[str],
117
+ *,
118
+ snapshot: bool = True,
119
+ since_seq: int | None = None,
120
+ boot_id: str | None = None,
121
+ ) -> str:
122
+ parsed = urllib.parse.urlsplit(base)
123
+ params = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
124
+ params["airports"] = ",".join(airports)
125
+ if snapshot:
126
+ params["snapshot"] = "true"
127
+ if since_seq is not None and boot_id:
128
+ params["since_seq"] = str(since_seq)
129
+ params["boot_id"] = boot_id
130
+ query = urllib.parse.urlencode(params)
131
+ return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/v1/ws/metar", query, ""))
132
+
133
+
134
+ def latest_url(base: str, airports: list[str]) -> str:
135
+ parsed = urllib.parse.urlsplit(base)
136
+ scheme = "https" if parsed.scheme in {"wss", "https"} else "http"
137
+ netloc = parsed.netloc
138
+ path = "/v1/metar/latest"
139
+ query = urllib.parse.urlencode({"airports": ",".join(airports)})
140
+ return urllib.parse.urlunsplit((scheme, netloc, path, query, ""))
141
+
142
+
143
+ def fetch_latest(url: str, token: str, airports: list[str]) -> list[dict]:
144
+ target = latest_url(url, airports)
145
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
146
+ request = urllib.request.Request(target, headers=headers)
147
+ try:
148
+ with urllib.request.urlopen(request, timeout=15) as response:
149
+ body = json.loads(response.read().decode())
150
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
151
+ return []
152
+ return list(body.get("data") or [])
153
+
154
+
155
+
156
+ def _outage_key(message: dict) -> str:
157
+ return str(message.get("outage_id") or message.get("icao") or "")
158
+
159
+
160
+ class MetarClient:
161
+ """Reconnecting client for /v1/ws/metar."""
162
+
163
+ fetch_latest = staticmethod(fetch_latest)
164
+
165
+ def __init__(
166
+ self,
167
+ airports: list[str],
168
+ *,
169
+ token: str | None = None,
170
+ url: str = DEFAULT_URL,
171
+ snapshot: bool = True,
172
+ resume: bool = True,
173
+ on_record: Callable[[dict], Any] | None = None,
174
+ on_frame: Callable[[dict], Any] | None = None,
175
+ ) -> None:
176
+ self.url = url
177
+ self.token = resolve_token(token)
178
+ self.airports = [name.upper() for name in airports]
179
+ self.snapshot = snapshot
180
+ self.resume = resume
181
+ self.on_record = on_record
182
+ self.on_frame = on_frame
183
+ self.latest: dict[str, dict] = {}
184
+ self.boot_id: str | None = None
185
+ self.seq: int | None = None
186
+ self._delay = BACKOFF_START_S
187
+ self._stop = asyncio.Event()
188
+ self._ws: Any = None
189
+ self.last_usage: dict | None = None
190
+ self.last_late: dict | None = None
191
+ self.last_recovered: dict | None = None
192
+ self.lates: dict[str, dict] = {} # latest station_late per outage_id
193
+
194
+ def handshake_url(self) -> str:
195
+ return handshake_url(
196
+ self.url,
197
+ self.airports,
198
+ snapshot=self.snapshot,
199
+ since_seq=self.seq if self.resume else None,
200
+ boot_id=self.boot_id if self.resume else None,
201
+ )
202
+
203
+ def handle_frame(self, message: dict) -> None:
204
+ kind = message.get("type")
205
+ if kind == "subscription_updated":
206
+ if message.get("boot_id"):
207
+ self.boot_id = message["boot_id"]
208
+ # Do not take seq from subscription_updated: it is the server's
209
+ # current seq, and would skip replay frames not yet received.
210
+ elif kind == "resume":
211
+ self._apply_resume(self.fetch_latest(self.url, self.token, self.airports))
212
+ elif kind == "new_metar_arrived":
213
+ live = message.get("cached") is False and message.get("replay") is False
214
+ replayed = message.get("replay") is True
215
+ for rec in message.get("data") or []:
216
+ before = self.latest.get(rec.get("icao") or "")
217
+ merge_record(self.latest, rec, live=live)
218
+ self._emit(rec.get("icao"), before)
219
+ if (live or replayed) and message.get("seq") is not None:
220
+ self._advance_seq(message["seq"])
221
+ elif kind == "awc_confirmed":
222
+ if message.get("seq") is not None:
223
+ self._advance_seq(message["seq"])
224
+ icao = message.get("icao")
225
+ before = dict(self.latest[icao]) if icao and icao in self.latest else None
226
+ apply_awc_confirmed(self.latest, message)
227
+ self._emit(icao, before)
228
+ elif kind in {"station_late", "station_recovered", "usage"}:
229
+ if message.get("seq") is not None:
230
+ self._advance_seq(message["seq"])
231
+ if kind == "usage":
232
+ self.last_usage = message
233
+ elif kind == "station_late":
234
+ self.last_late = message
235
+ self.lates[_outage_key(message)] = message
236
+ else:
237
+ self.last_recovered = message
238
+ key = _outage_key(message)
239
+ self.lates.pop(key, None)
240
+ held = self.last_late
241
+ if held is not None and _outage_key(held) == key:
242
+ self.last_late = None
243
+ # Unknown types (pong, server_notice, error, and any later type) are ignored.
244
+ if self.on_frame is not None:
245
+ self.on_frame(message)
246
+
247
+ def _advance_seq(self, seq: int) -> None:
248
+ if self.seq is None or seq > self.seq:
249
+ self.seq = seq
250
+
251
+ def _apply_resume(self, rows: list[dict]) -> None:
252
+ for rec in rows:
253
+ before = self.latest.get(rec.get("icao") or "")
254
+ merge_record(self.latest, rec, live=False)
255
+ self._emit(rec.get("icao"), before)
256
+
257
+ async def _handle_message(self, message: dict) -> None:
258
+ if message.get("type") == "resume":
259
+ rows = await asyncio.to_thread(self.fetch_latest, self.url, self.token, self.airports)
260
+ self._apply_resume(rows)
261
+ if self.on_frame is not None:
262
+ self.on_frame(message)
263
+ return
264
+ self.handle_frame(message)
265
+
266
+ def _emit(self, icao: str | None, before: dict | None) -> None:
267
+ if not icao or self.on_record is None:
268
+ return
269
+ held = self.latest.get(icao)
270
+ if held is not None and held is not before and held != before:
271
+ self.on_record(held)
272
+
273
+ async def request_usage(self) -> None:
274
+ """Send {"action":"usage"} on the open socket, if any."""
275
+ ws = self._ws
276
+ if ws is not None:
277
+ await ws.send(json.dumps({"action": "usage"}))
278
+
279
+ async def subscribe(self, icaos: list[str] | None = None, **fields: Any) -> None:
280
+ """Send a mid-session subscribe. Pass filter= or delta= to opt in."""
281
+ ws = self._ws
282
+ if ws is None:
283
+ return
284
+ body: dict[str, Any] = {"action": "subscribe", "icaos": list(icaos or [])}
285
+ body.update(fields)
286
+ await ws.send(json.dumps(body))
287
+
288
+ def stop(self) -> None:
289
+ self._stop.set()
290
+ ws = self._ws
291
+ if ws is not None:
292
+ try:
293
+ asyncio.get_running_loop().create_task(ws.close())
294
+ except RuntimeError:
295
+ pass
296
+
297
+ async def _session(self) -> str | int | None:
298
+ headers = {"Authorization": f"Bearer {self.token}"}
299
+ opened: float | None = None
300
+ saw_message = False
301
+ ping_task: asyncio.Task | None = None
302
+ try:
303
+ async with connect(self.handshake_url(), additional_headers=headers) as ws:
304
+ opened = time.monotonic()
305
+ self._ws = ws
306
+
307
+ async def heartbeat() -> None:
308
+ while True:
309
+ await asyncio.sleep(PING_EVERY_S)
310
+ if self._stop.is_set():
311
+ return
312
+ await ws.send(json.dumps({"action": "ping"}))
313
+ try:
314
+ await asyncio.wait_for(pong.wait(), timeout=PONG_TIMEOUT_S)
315
+ except asyncio.TimeoutError:
316
+ await ws.close(code=1001, reason="pong timeout")
317
+ return
318
+ finally:
319
+ pong.clear()
320
+
321
+ pong = asyncio.Event()
322
+ ping_task = asyncio.create_task(heartbeat())
323
+ async for text in ws:
324
+ if self._stop.is_set():
325
+ break
326
+ if not saw_message:
327
+ saw_message = True
328
+ self._delay = BACKOFF_START_S
329
+ message = json.loads(text)
330
+ if message.get("type") == "pong":
331
+ pong.set()
332
+ await self._handle_message(message)
333
+ except ConnectionClosed as exc:
334
+ if saw_message or (opened is not None and time.monotonic() - opened >= QUIET_OPEN_S):
335
+ self._delay = BACKOFF_START_S
336
+ return exc.rcvd.code if exc.rcvd is not None else exc.sent.code if exc.sent is not None else 1006
337
+ except (InvalidStatus, OSError, TimeoutError, asyncio.TimeoutError) as exc:
338
+ return connect_failure_code(exc)
339
+ finally:
340
+ self._ws = None
341
+ if ping_task is not None:
342
+ ping_task.cancel()
343
+ if opened is not None and (saw_message or time.monotonic() - opened >= QUIET_OPEN_S):
344
+ self._delay = BACKOFF_START_S
345
+ return None
346
+
347
+ async def run_forever(self) -> None:
348
+ self._stop = asyncio.Event()
349
+ while not self._stop.is_set():
350
+ code = await self._session()
351
+ if self._stop.is_set():
352
+ return
353
+ if not should_retry(code if isinstance(code, int) else None):
354
+ return
355
+ wait = self._delay * (0.5 + random.random())
356
+ self._delay = min(self._delay * 2.0, BACKOFF_CAP_S)
357
+ try:
358
+ await asyncio.wait_for(self._stop.wait(), timeout=wait)
359
+ return
360
+ except asyncio.TimeoutError:
361
+ pass
362
+
363
+
364
+ class MetarExample(MetarClient):
365
+ """Backward-compatible constructor: (url, token, airports)."""
366
+
367
+ def __init__(self, url: str, token: str, airports: list[str], **kwargs) -> None:
368
+ super().__init__(airports, token=token, url=url, **kwargs)
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: mobs-ws
3
+ Version: 0.16.1
4
+ Summary: Official client for the MOBS /v1/ws/metar WebSocket (protocol 4).
5
+ Author-email: MOBS <admin@mobs.ws>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://mobs.ws
8
+ Project-URL: Documentation, https://docs.mobs.ws/websocket
9
+ Project-URL: Repository, https://github.com/mobs-ws/mobs-sdks
10
+ Project-URL: Issues, https://github.com/mobs-ws/mobs-sdks/issues
11
+ Keywords: mobs,metar,websocket
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Internet
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: websockets>=17.3
21
+ Dynamic: license-file
22
+
23
+ # mobs-ws
24
+
25
+ Official Python client for the MOBS `/v1/ws/metar` WebSocket (protocol 4).
26
+
27
+ The only runtime dependency is `websockets`. HTTP backfill uses urllib from
28
+ the standard library. Snapshot and resume are opt-in. The client applies
29
+ `awc_confirmed` frames last-wins per (`icao`, `versions`), handles
30
+ `station_late`, `station_recovered`, and `usage` (use `request_usage()`),
31
+ and ignores unknown frame types. Late alerts are kept as the latest frame per `outage_id` and cleared on `station_recovered`. More than one `station_late` can arrive in the same outage. A change-only `filter` and parsed
32
+ `delta` are opt-in on a mid-session `subscribe`, not on the handshake.
33
+
34
+ The API key is never embedded. Pass it to the constructor, or set
35
+ `MOBS_TOKEN`.
36
+
37
+ ```bash
38
+ pip install mobs-ws
39
+ export MOBS_TOKEN=$MOBS_TOKEN
40
+ ```
41
+
42
+ ```python
43
+ import asyncio
44
+ import os
45
+
46
+ from mobs_ws import MetarClient
47
+
48
+ async def main():
49
+ client = MetarClient(
50
+ airports=["KNYC", "KLGA"],
51
+ token=os.environ["MOBS_TOKEN"], # or token="YOUR_API_KEY"
52
+ snapshot=True,
53
+ resume=True,
54
+ on_record=lambda rec: print(rec["icao"], rec["raw_metar"], rec.get("awc_confirmed")),
55
+ )
56
+ await client.run_forever()
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ ```bash
62
+ python -m mobs_ws --airports KNYC,KLGA
63
+ ```
64
+
65
+ MIT licensed. See [LICENSE](https://github.com/mobs-ws/mobs-sdks/blob/main/LICENSE).
@@ -0,0 +1,9 @@
1
+ mobs_ws/__init__.py,sha256=7yYKkgFVvARmyQQtG9u7kttzwUce2URcZhAgFRNvfN0,759
2
+ mobs_ws/__main__.py,sha256=Xt7lsR-7i-ZSN_ItPXuy7P2hgVoBAmekmlD1r_PWzCo,887
3
+ mobs_ws/client.py,sha256=LQk_qqMiAuXRTbezl0eYoNFuQpLuNTehzFDwNk4I5PM,14126
4
+ mobs_ws-0.16.1.dist-info/licenses/LICENSE,sha256=dw8JDhF6WUev5XCo8VXWjhIoWrKnhggaf8uGqlFVxjU,1061
5
+ mobs_ws-0.16.1.dist-info/METADATA,sha256=gOYIQbeJiX90w2j3K0jrkD2QPbf97vQ9pLtRkrS54kk,2180
6
+ mobs_ws-0.16.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ mobs_ws-0.16.1.dist-info/entry_points.txt,sha256=R1cAdC_WXpxwzRsEqyNFKozBcuOJbetN1yIqsX647eA,50
8
+ mobs_ws-0.16.1.dist-info/top_level.txt,sha256=vh74LSgCI8r4NLZ85fXiV3tdqyJu_Q3zLtKPyjEC3T8,8
9
+ mobs_ws-0.16.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mobs-ws = mobs_ws.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MOBS
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 @@
1
+ mobs_ws