jf8net 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jf8net-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: jf8net
3
+ Version: 0.1.0
4
+ Summary: Python library for the JF8Call WebSocket API
5
+ License: GPL-3.0-or-later
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: websockets>=11.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest; extra == "dev"
10
+ Requires-Dist: pytest-asyncio; extra == "dev"
jf8net-0.1.0/README.md ADDED
@@ -0,0 +1,519 @@
1
+ # jf8net
2
+
3
+ Python library for the [JF8Call](https://github.com/jfrancis42/jf8call) WebSocket API.
4
+
5
+ jf8net provides complete coverage of JF8Call's WebSocket API with asyncio
6
+ and typed dataclasses.
7
+
8
+ ---
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install websockets
14
+ pip install -e /path/to/jf8net # editable install from source
15
+ ```
16
+
17
+ **Requirements:** Python 3.10+, `websockets` 11+.
18
+
19
+ JF8Call must be running with the WebSocket API enabled (the default) on
20
+ `ws://localhost:2102`.
21
+
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ ### Async (recommended)
27
+
28
+ ```python
29
+ import asyncio
30
+ from jf8net import JF8Client, DecodedMessage
31
+
32
+ async def main():
33
+ async with JF8Client() as client:
34
+ # Print current status
35
+ print(await client.get_status())
36
+
37
+ # Receive messages in real time
38
+ @client.on_message
39
+ async def handle(msg: DecodedMessage):
40
+ print(f"{msg.from_call} → {msg.to}: {msg.body}")
41
+
42
+ await client.run_forever()
43
+
44
+ asyncio.run(main())
45
+ ```
46
+
47
+ ### Sync (for scripts)
48
+
49
+ ```python
50
+ from jf8net.sync import JF8ClientSync
51
+
52
+ with JF8ClientSync() as client:
53
+ print(client.get_status())
54
+ for msg in client.messages(): # blocks, yields DecodedMessage
55
+ print(msg)
56
+ ```
57
+
58
+ ---
59
+
60
+ ## API Reference
61
+
62
+ ### `JF8Client` — Async Client
63
+
64
+ ```python
65
+ from jf8net import JF8Client
66
+ client = JF8Client(host="localhost", port=2102,
67
+ auto_reconnect=True, reconnect_delay=5.0, cmd_timeout=10.0)
68
+ ```
69
+
70
+ Use as an async context manager (`async with`) or call `await client.connect()`
71
+ and `await client.disconnect()` manually.
72
+
73
+ #### Status & Configuration
74
+
75
+ ```python
76
+ status: Status = await client.get_status()
77
+ config: Config = await client.get_config()
78
+ config: Config = await client.set_config(**kwargs)
79
+ ```
80
+
81
+ `set_config` accepts any subset of `Config` fields as keyword arguments
82
+ using Python snake_case names:
83
+
84
+ ```python
85
+ await client.set_config(
86
+ callsign="W5XYZ",
87
+ grid="DM79AA",
88
+ station_info="QTH: Austin TX PWR: 100W ANT: 80m Dipole",
89
+ station_status="Available for sked",
90
+ cq_message="CQ CQ DE W5XYZ",
91
+ heartbeat_enabled=True,
92
+ heartbeat_interval_periods=4,
93
+ auto_reply=True,
94
+ auto_atu=True,
95
+ psk_reporter_enabled=True,
96
+ dist_miles=False,
97
+ )
98
+ ```
99
+
100
+ #### Audio
101
+
102
+ ```python
103
+ devices: AudioDevices = await client.get_audio_devices()
104
+ # devices.inputs → list[str]
105
+ # devices.outputs → list[str]
106
+
107
+ await client.restart_audio()
108
+ ```
109
+
110
+ #### Radio (Hamlib)
111
+
112
+ ```python
113
+ radio: RadioStatus = await client.get_radio()
114
+
115
+ await client.connect_radio(
116
+ rig_model=3073, # IC-7300
117
+ port="/dev/ttyUSB0",
118
+ baud=19200,
119
+ ptt_type=1, # 0=VOX 1=CAT 2=DTR 3=RTS
120
+ # data_bits, stop_bits, parity, handshake, dtr_state, rts_state also available
121
+ )
122
+ await client.disconnect_radio()
123
+
124
+ freq_khz: float = await client.set_frequency(14078.0)
125
+ await client.tune() # RIG_OP_TUNE — no RF, just CAT ATU command
126
+ await client.set_ptt(True) # direct PTT (use with care)
127
+ ```
128
+
129
+ When `auto_atu=True` is set in config, `set_frequency()` automatically
130
+ triggers `tune()` after every successful frequency change.
131
+
132
+ #### Transmit
133
+
134
+ ```python
135
+ queue_size: int = await client.send("W4ABC DE W5XYZ HELLO 73")
136
+ queue_size: int = await client.send("W4ABC QUICK", submode="fast")
137
+
138
+ await client.send_heartbeat()
139
+ await client.send_snr_query("W4ABC")
140
+ await client.send_info_query("W4ABC")
141
+ await client.send_status_query("W4ABC")
142
+
143
+ frames: list[TxFrame] = await client.get_tx_queue()
144
+ await client.clear_tx_queue()
145
+
146
+ # Send and block until TX completes
147
+ await client.send_and_wait("W4ABC DE W5XYZ 73", timeout=120)
148
+ ```
149
+
150
+ Valid `submode` values for GFSK8: `"normal"` (or `0`), `"fast"` (`1`),
151
+ `"turbo"` (`2`), `"slow"` (`3`), `"ultra"` (`4`).
152
+
153
+ #### Messages & Spectrum
154
+
155
+ ```python
156
+ messages: list[DecodedMessage] = await client.get_messages(offset=0, limit=100)
157
+ await client.clear_messages()
158
+
159
+ spectrum: Spectrum = await client.get_spectrum()
160
+ peak_hz = spectrum.peak_hz()
161
+ slice_db = spectrum.slice(500, 2500) # bins between 500–2500 Hz
162
+ ```
163
+
164
+ #### Waiting for TX
165
+
166
+ ```python
167
+ await client.send("W4ABC HELLO")
168
+ await client.wait_for_tx(timeout=120) # blocks until tx.finished event
169
+ ```
170
+
171
+ ---
172
+
173
+ ### Event Handlers
174
+
175
+ Register handlers using decorators or direct calls. Handlers may be plain
176
+ functions or coroutines.
177
+
178
+ ```python
179
+ # Decorator style
180
+ @client.on_message
181
+ async def handle_decoded(msg: DecodedMessage):
182
+ print(msg)
183
+
184
+ # Direct registration
185
+ def my_handler(msg): print(msg)
186
+ client.on("message.decoded", my_handler)
187
+ client.off("message.decoded", my_handler)
188
+ ```
189
+
190
+ #### Available Events
191
+
192
+ | Event | Convenience method | Typed argument |
193
+ |-------|-------------------|----------------|
194
+ | `message.decoded` | `on_message` | `DecodedMessage` |
195
+ | `message.frame` | `on_frame` | `FrameUpdate` |
196
+ | `status` | `on_status` | `Status` |
197
+ | `spectrum` | `on_spectrum` | `Spectrum` |
198
+ | `tx.started` | `on_tx_started` | `None` |
199
+ | `tx.finished` | `on_tx_finished` | `None` |
200
+ | `radio.connected` | `on_radio_connected` | `dict` |
201
+ | `radio.disconnected` | `on_radio_disconnected` | `None` |
202
+ | `config.changed` | `on_config_changed` | `dict` |
203
+ | `"*"` | — | any (typed or dict) |
204
+
205
+ #### `message.frame` — Partial GFSK8 Frames
206
+
207
+ For multi-frame GFSK8 messages (e.g. a long Normal-mode message spanning
208
+ several 15-second periods), JF8Call emits a `message.frame` event for each
209
+ frame as it arrives. This lets you display in-progress messages in real time
210
+ rather than waiting for the complete assembled message.
211
+
212
+ ```python
213
+ @client.on_frame
214
+ async def on_partial(frame: FrameUpdate):
215
+ print(f"[{frame.frame_type_name.upper()}] +{frame.freq_hz:.0f}Hz {frame.assembled_text!r}")
216
+ # frame.assembled_text grows with each frame
217
+ # frame.freq_key = round(frame.freq_hz / 10) — use this to correlate frames
218
+
219
+ @client.on_message
220
+ async def on_complete(msg: DecodedMessage):
221
+ # Fires once after the last frame; freq_key is the same as the frame events
222
+ print(f"COMPLETE: {msg}")
223
+ ```
224
+
225
+ `message.frame` is only emitted for first and middle frames of multi-frame
226
+ GFSK8 messages. Single-frame messages and streaming modem (PSK/Olivia/Codec2)
227
+ chunks go directly to `message.decoded`.
228
+
229
+ ---
230
+
231
+ ### Data Classes
232
+
233
+ #### `DecodedMessage`
234
+
235
+ ```python
236
+ msg.time # datetime (UTC)
237
+ msg.freq_hz # float — audio frequency in Hz
238
+ msg.snr_db # int — signal-to-noise ratio
239
+ msg.submode # int
240
+ msg.submode_name # str (e.g. "Normal")
241
+ msg.from_call # str — sender callsign
242
+ msg.to # str — destination callsign (empty = broadcast)
243
+ msg.body # str — parsed message body
244
+ msg.raw # str — full raw text
245
+ msg.type # int — MessageType constant
246
+ msg.type_name # str (e.g. "DirectedMessage")
247
+ msg.freq_key # int — round(freq_hz/10), for frame correlation
248
+ msg.is_directed # bool
249
+ msg.is_heartbeat # bool
250
+ ```
251
+
252
+ #### `FrameUpdate`
253
+
254
+ ```python
255
+ frame.time # datetime (UTC)
256
+ frame.freq_hz # float
257
+ frame.snr_db # int
258
+ frame.submode # int
259
+ frame.submode_name # str
260
+ frame.frame_type # int — FrameType constant (0=middle, 1=first)
261
+ frame.frame_type_name # str ("first", "middle")
262
+ frame.frame_text # str — raw text of just this frame
263
+ frame.assembled_text # str — everything accumulated so far
264
+ frame.is_complete # bool — always False for this event
265
+ frame.freq_key # int — round(freq_hz/10)
266
+ ```
267
+
268
+ #### `Status`
269
+
270
+ ```python
271
+ status.callsign # str
272
+ status.grid # str
273
+ status.submode # int
274
+ status.submode_name # str
275
+ status.frequency_khz # float
276
+ status.tx_freq_hz # float
277
+ status.transmitting # bool
278
+ status.audio_running # bool
279
+ status.radio_connected # bool
280
+ status.radio_freq_khz # float
281
+ status.radio_mode # str
282
+ status.tx_queue_size # int
283
+ status.heartbeat_enabled # bool
284
+ status.heartbeat_interval_periods # int
285
+ status.auto_reply # bool
286
+ status.ws_port # int
287
+ status.ws_clients # int
288
+ ```
289
+
290
+ #### `Config`
291
+
292
+ All `Status` fields plus:
293
+
294
+ ```python
295
+ config.audio_input_name # str
296
+ config.audio_output_name # str
297
+ config.modem_type # int (ModemType.GFSK8 etc.)
298
+ config.tx_power_pct # int
299
+ config.station_info # str
300
+ config.station_status # str
301
+ config.cq_message # str
302
+ config.dist_miles # bool
303
+ config.auto_atu # bool
304
+ config.psk_reporter_enabled # bool
305
+ config.rig_model # int
306
+ config.rig_port # str
307
+ config.rig_baud # int
308
+ config.rig_data_bits # int
309
+ config.rig_stop_bits # int
310
+ config.rig_parity # int
311
+ config.rig_handshake # int
312
+ config.rig_dtr_state # int
313
+ config.rig_rts_state # int
314
+ config.ptt_type # int
315
+ config.ws_enabled # bool
316
+ config.ws_port # int
317
+ ```
318
+
319
+ #### Constants
320
+
321
+ ```python
322
+ from jf8net import MessageType, FrameType, ModemType, PttType
323
+
324
+ MessageType.HEARTBEAT # 1
325
+ MessageType.DIRECTED # 2
326
+ MessageType.SNR_QUERY # 3
327
+ MessageType.SNR_REPLY # 4
328
+ MessageType.INFO_QUERY # 5
329
+ MessageType.INFO_REPLY # 6
330
+ MessageType.STATUS_QUERY # 7
331
+ MessageType.STATUS_REPLY # 8
332
+
333
+ FrameType.FIRST # 1
334
+ FrameType.MIDDLE # 0
335
+ FrameType.LAST # 2
336
+ FrameType.SINGLE # 3
337
+
338
+ ModemType.GFSK8 # 0 — GFSK8 (default)
339
+ ModemType.CODEC2 # 1
340
+ ModemType.OLIVIA # 2
341
+ ModemType.PSK # 3
342
+
343
+ PttType.VOX # 0
344
+ PttType.CAT # 1
345
+ PttType.DTR # 2
346
+ PttType.RTS # 3
347
+ ```
348
+
349
+ ---
350
+
351
+ ### `JF8ClientSync` — Synchronous Client
352
+
353
+ ```python
354
+ from jf8net.sync import JF8ClientSync
355
+
356
+ with JF8ClientSync(host="localhost", port=2102) as client:
357
+ status = client.get_status()
358
+ config = client.get_config()
359
+ config = client.set_config(callsign="W5XYZ")
360
+
361
+ devices = client.get_audio_devices()
362
+ radio = client.get_radio()
363
+
364
+ client.set_frequency(14078.0)
365
+ client.tune()
366
+
367
+ client.send("W4ABC HELLO")
368
+ client.wait_for_tx()
369
+
370
+ # Generator — blocks, yields DecodedMessage
371
+ for msg in client.messages():
372
+ print(msg)
373
+
374
+ # Generator — also yields FrameUpdate for partial frames
375
+ for item in client.messages(include_frames=True):
376
+ print(item)
377
+
378
+ # With a timeout (raises StopIteration after N seconds of silence)
379
+ for msg in client.messages(timeout=60.0):
380
+ print(msg)
381
+ ```
382
+
383
+ Event handlers in sync mode are plain functions:
384
+
385
+ ```python
386
+ with JF8ClientSync() as client:
387
+ @client.on_message
388
+ def handle(msg):
389
+ print(msg)
390
+
391
+ @client.on_frame
392
+ def on_partial(frame):
393
+ print(frame.assembled_text)
394
+
395
+ client.run_forever() # blocks until disconnect
396
+ ```
397
+
398
+ ---
399
+
400
+ ## Examples
401
+
402
+ | File | Description |
403
+ |------|-------------|
404
+ | `examples/01_basic_status.py` | Status, config, audio devices, recent messages |
405
+ | `examples/02_receive_messages.py` | Stream decoded messages with filters |
406
+ | `examples/03_send_message.py` | Send directed messages and wait for TX |
407
+ | `examples/04_frame_assembly.py` | Real-time multi-frame GFSK8 assembly display |
408
+ | `examples/05_radio_control.py` | Frequency changes, ATU tuning, rig connect |
409
+ | `examples/06_config_management.py` | Read/write all config fields |
410
+ | `examples/07_spectrum_monitor.py` | ASCII waterfall from live spectrum events |
411
+ | `examples/08_sync_usage.py` | All of the above using the sync wrapper |
412
+ | `examples/09_chat.py` | Interactive two-way terminal chat with a specific callsign |
413
+
414
+ Run any example with `--help` for its options, and `--host` to point at a
415
+ remote JF8Call instance.
416
+
417
+ ---
418
+
419
+ ## Recipes
420
+
421
+ ### Auto-respond to directed messages
422
+
423
+ ```python
424
+ async with JF8Client() as client:
425
+ status = await client.get_status()
426
+ my_call = status.callsign
427
+
428
+ @client.on_message
429
+ async def respond(msg: DecodedMessage):
430
+ if msg.to == my_call and msg.body == "PING":
431
+ await client.send(f"{msg.from_call} PONG")
432
+
433
+ await client.run_forever()
434
+ ```
435
+
436
+ ### Log all decoded messages to a JSONL file
437
+
438
+ ```python
439
+ import json
440
+ from datetime import datetime
441
+
442
+ async with JF8Client() as client:
443
+ with open("rx.jsonl", "a") as f:
444
+ @client.on_message
445
+ def log(msg: DecodedMessage):
446
+ f.write(json.dumps({
447
+ "time": msg.time.isoformat(),
448
+ "freq_hz": msg.freq_hz,
449
+ "snr_db": msg.snr_db,
450
+ "from": msg.from_call,
451
+ "to": msg.to,
452
+ "body": msg.body,
453
+ "raw": msg.raw,
454
+ }) + "\n")
455
+ f.flush()
456
+ await client.run_forever()
457
+ ```
458
+
459
+ ### Band-change macro with ATU
460
+
461
+ ```python
462
+ JS8_BANDS = {20: 14078.0, 40: 7078.0, 80: 3578.0}
463
+
464
+ async def switch_band(client: JF8Client, meters: int) -> None:
465
+ khz = JS8_BANDS[meters]
466
+ await client.set_frequency(khz)
467
+ # If auto_atu is off, trigger manually:
468
+ cfg = await client.get_config()
469
+ if not cfg.auto_atu:
470
+ await client.tune()
471
+ print(f"On {meters}m ({khz} kHz)")
472
+ ```
473
+
474
+ ### Monitor multiple JF8Call instances simultaneously
475
+
476
+ ```python
477
+ import asyncio
478
+ from jf8net import JF8Client, DecodedMessage
479
+
480
+ STATIONS = [
481
+ ("localhost", 2102, "greybox"),
482
+ ("192.168.86.50", 2102, "shack2"),
483
+ ]
484
+
485
+ async def watch(host, port, name):
486
+ async with JF8Client(host=host, port=port) as client:
487
+ @client.on_message
488
+ def handle(msg: DecodedMessage):
489
+ print(f"[{name}] {msg}")
490
+ await client.run_forever()
491
+
492
+ async def main():
493
+ await asyncio.gather(*[watch(*s) for s in STATIONS])
494
+
495
+ asyncio.run(main())
496
+ ```
497
+
498
+ ### Track per-station SNR over time
499
+
500
+ ```python
501
+ from collections import defaultdict
502
+ spots = defaultdict(list) # call → [snr, ...]
503
+
504
+ @client.on_message
505
+ def track_snr(msg: DecodedMessage):
506
+ if msg.from_call:
507
+ spots[msg.from_call].append(msg.snr_db)
508
+
509
+ # Later:
510
+ for call, readings in sorted(spots.items()):
511
+ avg = sum(readings) / len(readings)
512
+ print(f"{call}: {len(readings)} readings, avg SNR {avg:+.1f} dB")
513
+ ```
514
+
515
+ ---
516
+
517
+ ## License
518
+
519
+ GPL-3.0-or-later (matching JF8Call's license).
@@ -0,0 +1,73 @@
1
+ """
2
+ jf8net — Python library for the JF8Call WebSocket API.
3
+
4
+ Quick start (async)::
5
+
6
+ import asyncio
7
+ from jf8net import JF8Client, DecodedMessage
8
+
9
+ async def main():
10
+ async with JF8Client() as client:
11
+ status = await client.get_status()
12
+ print(status)
13
+
14
+ @client.on_message
15
+ async def handle(msg: DecodedMessage):
16
+ print(msg)
17
+
18
+ await client.run_forever()
19
+
20
+ asyncio.run(main())
21
+
22
+ Quick start (sync)::
23
+
24
+ from jf8net.sync import JF8ClientSync
25
+
26
+ with JF8ClientSync() as client:
27
+ print(client.get_status())
28
+ for msg in client.messages():
29
+ print(msg)
30
+ """
31
+
32
+ from ._client import JF8Client, JF8Error, ConnectionError
33
+ from ._models import (
34
+ DecodedMessage,
35
+ FrameUpdate,
36
+ Status,
37
+ Config,
38
+ RadioStatus,
39
+ TxFrame,
40
+ AudioDevices,
41
+ Spectrum,
42
+ MessageType,
43
+ FrameType,
44
+ ModemType,
45
+ PttType,
46
+ BandEntry,
47
+ SolarData,
48
+ QsoEntry,
49
+ InboxMessage,
50
+ )
51
+
52
+ __version__ = "0.1.0"
53
+ __all__ = [
54
+ "JF8Client",
55
+ "JF8Error",
56
+ "ConnectionError",
57
+ "DecodedMessage",
58
+ "FrameUpdate",
59
+ "Status",
60
+ "Config",
61
+ "RadioStatus",
62
+ "TxFrame",
63
+ "AudioDevices",
64
+ "Spectrum",
65
+ "MessageType",
66
+ "FrameType",
67
+ "ModemType",
68
+ "PttType",
69
+ "BandEntry",
70
+ "SolarData",
71
+ "QsoEntry",
72
+ "InboxMessage",
73
+ ]