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 +10 -0
- jf8net-0.1.0/README.md +519 -0
- jf8net-0.1.0/jf8net/__init__.py +73 -0
- jf8net-0.1.0/jf8net/_client.py +666 -0
- jf8net-0.1.0/jf8net/_models.py +363 -0
- jf8net-0.1.0/jf8net/_parsers.py +248 -0
- jf8net-0.1.0/jf8net/sync.py +327 -0
- jf8net-0.1.0/jf8net.egg-info/PKG-INFO +10 -0
- jf8net-0.1.0/jf8net.egg-info/SOURCES.txt +12 -0
- jf8net-0.1.0/jf8net.egg-info/dependency_links.txt +1 -0
- jf8net-0.1.0/jf8net.egg-info/requires.txt +5 -0
- jf8net-0.1.0/jf8net.egg-info/top_level.txt +1 -0
- jf8net-0.1.0/pyproject.toml +18 -0
- jf8net-0.1.0/setup.cfg +4 -0
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
|
+
]
|