qrss-ei 0.0.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.
qrss_ei/__init__.py ADDED
@@ -0,0 +1,202 @@
1
+ import argparse
2
+ import collections.abc
3
+ import logging
4
+ import sys
5
+ import time
6
+ import typing
7
+
8
+ from cw_enc.cw_coder import CWCoder, raise_on_notmorseable_char
9
+ from cw_enc.paddle_samplecount import PaddleSampleCount
10
+
11
+ from qrss_ei.qrss_paddle import QRSS_Paddle
12
+ from qrss_ei.tx import AbstractConnectedTx, connect_to_rigctld_factory
13
+ from qrss_ei.waiting import AbstractWaiter, Waiter
14
+
15
+ QRSS_EI_VERSION = "0.0.1"
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def count_dot_times(intext: str) -> int:
21
+ with PaddleSampleCount(
22
+ dit_samples=2, dah_samples=4, ci_silence_samples=2, iw_silence_samples=4
23
+ ) as dot_counter:
24
+ coder = CWCoder(paddle=dot_counter, on_missing=raise_on_notmorseable_char)
25
+ for c in intext:
26
+ coder.code(c)
27
+ return typing.cast(PaddleSampleCount, dot_counter).count()
28
+
29
+
30
+ def inner_inner(
31
+ intext: str,
32
+ dot_length: float,
33
+ period_s: float,
34
+ repeats: int,
35
+ qrg: float,
36
+ deltaf: float,
37
+ connect_to_rigctld: typing.Callable[[], typing.ContextManager[AbstractConnectedTx]],
38
+ waiter: AbstractWaiter,
39
+ duration: float,
40
+ ) -> None:
41
+ if period_s < duration:
42
+ raise ValueError(
43
+ f"A {duration} seconds message does not fit into {period_s} s period."
44
+ )
45
+ start_of_everything_µs = waiter.get_now()
46
+ with connect_to_rigctld() as connected_tx:
47
+ start_for_loop_iteration_µs = waiter.get_now()
48
+ logger.debug(
49
+ "Starting to send at %.2f s into hour",
50
+ start_for_loop_iteration_µs % (3600 * 1000000) * 1e-6,
51
+ )
52
+ for loop_count in range(0, repeats):
53
+ with QRSS_Paddle(
54
+ dot_length, qrg, qrg + deltaf, connected_tx, waiter
55
+ ) as paddle:
56
+ coder = CWCoder(paddle=paddle, on_missing=raise_on_notmorseable_char)
57
+ for c in intext:
58
+ coder.code(c)
59
+ if loop_count + 1 < repeats:
60
+ start_for_loop_iteration_µs += round(period_s * 1e6)
61
+ logger.debug(
62
+ "Want to wait until %.2f s into hour",
63
+ start_of_everything_µs % (3600 * 1000000) * 1e-6,
64
+ )
65
+
66
+ waiter.wait_until(start_for_loop_iteration_µs)
67
+
68
+
69
+ def main_inner(
70
+ intext: str,
71
+ dot_length: float,
72
+ count_only: bool,
73
+ period_seconds: float,
74
+ repeats: int,
75
+ qrg: float,
76
+ deltaf: float,
77
+ host: str,
78
+ port: int,
79
+ ) -> None:
80
+ dot_count = count_dot_times(intext)
81
+ duration = dot_count * dot_length
82
+ logger.info(
83
+ 'Message "%s" takes %d dot lengths %.1f s to tx.', intext, dot_count, duration
84
+ )
85
+ if count_only:
86
+ pass
87
+ else:
88
+ connect_to_rigctld = connect_to_rigctld_factory(host, port)
89
+ inner_inner(
90
+ intext,
91
+ dot_length,
92
+ period_seconds,
93
+ repeats,
94
+ qrg,
95
+ deltaf,
96
+ connect_to_rigctld,
97
+ Waiter(),
98
+ duration,
99
+ )
100
+
101
+
102
+ def setup_logging(verbose: bool) -> None:
103
+ logging.Formatter.converter = time.gmtime
104
+ logging.basicConfig(
105
+ format="%(asctime)sZ %(levelname)5s: %(message)s",
106
+ level=logging.DEBUG if verbose else logging.INFO,
107
+ )
108
+
109
+
110
+ def main(
111
+ parse_us: list[str] = sys.argv[1:],
112
+ inner: collections.abc.Callable[
113
+ [str, float, bool, float, int, float, float, str, int],
114
+ None,
115
+ ] = main_inner,
116
+ ) -> None:
117
+ argp = argparse.ArgumentParser(
118
+ prog="qrss_ei",
119
+ description="Simple software to send QRSS CW transmissions, "
120
+ "at home at https://codeberg.org/omwb/qrss-ei",
121
+ )
122
+
123
+ argp.add_argument(
124
+ "--intext", type=str, required=True, help="The text to be sent. Required."
125
+ )
126
+
127
+ argp.add_argument(
128
+ "--dotlength",
129
+ type=float,
130
+ default=3.0,
131
+ help="The dot length in seconds, float, default 3.0.",
132
+ )
133
+ argp.add_argument(
134
+ "--count",
135
+ action="store_true",
136
+ help="Only analyze the text length, without actually sending anything.",
137
+ )
138
+ argp.add_argument(
139
+ "--period",
140
+ type=float,
141
+ default=10,
142
+ help="After how many minutes to repeat the transmission (if at all), default 10.",
143
+ )
144
+ argp.add_argument(
145
+ "--repeats",
146
+ type=int,
147
+ default=1,
148
+ help="How often to transmit the same text, default 1.",
149
+ )
150
+ argp.add_argument(
151
+ "--qrg",
152
+ type=float,
153
+ default=10139842,
154
+ help="Lower (off) frequency on which you want to transmit, in Hz, default 10139842",
155
+ )
156
+ argp.add_argument(
157
+ "--deltaf",
158
+ type=float,
159
+ default=5,
160
+ help="Delta between off and on frequency, in Hz, default 5",
161
+ )
162
+
163
+ argp.add_argument(
164
+ "--host",
165
+ type=str,
166
+ help="Host running rigctld, default 127.0.0.1",
167
+ default="127.0.0.1",
168
+ )
169
+
170
+ argp.add_argument(
171
+ "--port",
172
+ type=int,
173
+ help="Port running rigctld, default 4532",
174
+ default=4532,
175
+ )
176
+
177
+ argp.add_argument(
178
+ "-v",
179
+ "--verbose",
180
+ action="store_true",
181
+ help="Produce detailed, verbose output telling you what's being done.",
182
+ )
183
+
184
+ args = argp.parse_args(parse_us)
185
+
186
+ setup_logging(args.verbose)
187
+
188
+ inner(
189
+ args.intext,
190
+ args.dotlength,
191
+ args.count,
192
+ args.period * 60,
193
+ args.repeats,
194
+ args.qrg,
195
+ args.deltaf,
196
+ args.host,
197
+ args.port,
198
+ )
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
qrss_ei/qrss_paddle.py ADDED
@@ -0,0 +1,69 @@
1
+ import logging
2
+ from types import TracebackType
3
+ from typing import Literal, Optional, Type
4
+
5
+ from cw_enc.paddle import Paddle
6
+
7
+ from qrss_ei.tx import AbstractConnectedTx
8
+ from qrss_ei.waiting import AbstractWaiter
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class QRSS_Paddle(Paddle):
14
+ def __init__(
15
+ self,
16
+ dot_length: float,
17
+ qrg_up: float,
18
+ qrg_down: float,
19
+ tx: AbstractConnectedTx,
20
+ waiter: AbstractWaiter,
21
+ ):
22
+ self._dot_length_µs = round(dot_length * 1e6)
23
+ self._qrg_down = qrg_down
24
+ self._qrg_up = qrg_up
25
+ self._tx = tx
26
+ self._waiter = waiter
27
+ self._next_end_of_signal_µs = self._waiter.get_now()
28
+ self._start_of_everything = self._next_end_of_signal_µs
29
+
30
+ def _signal(self, µs_down: int, µs_up: int) -> None:
31
+ self._waiter.wait_until(self._next_end_of_signal_µs)
32
+ if 0 < µs_down:
33
+ self._tx.ptt_up()
34
+ self._tx.set_qrg(self._qrg_down)
35
+ self._tx.ptt_down()
36
+ self._waiter.wait_until(self._next_end_of_signal_µs + µs_down)
37
+ self._tx.ptt_up()
38
+ self._tx.set_qrg(self._qrg_up)
39
+ self._tx.ptt_down()
40
+ self._next_end_of_signal_µs += µs_down + µs_up
41
+
42
+ def dit(self) -> None:
43
+ logger.debug(
44
+ "Dit to start at %.2f s into hour",
45
+ (self._next_end_of_signal_µs % (3600 * 1000000)) * 1e-6,
46
+ )
47
+ self._signal(self._dot_length_µs, self._dot_length_µs)
48
+
49
+ def dah(self) -> None:
50
+ logger.debug(
51
+ "Dah to start at %.2f s into hour",
52
+ (self._next_end_of_signal_µs % (3600 * 1000000)) * 1e-6,
53
+ )
54
+ self._signal(self._dot_length_µs * 3, self._dot_length_µs)
55
+
56
+ def csilence(self) -> None:
57
+ self._signal(0, 2 * self._dot_length_µs)
58
+
59
+ def iwsilence(self) -> None:
60
+ self._signal(0, 4 * self._dot_length_µs)
61
+
62
+ def __exit__(
63
+ self,
64
+ exc_type: Optional[Type[BaseException]],
65
+ exc_val: Optional[BaseException],
66
+ exc_tb: Optional[TracebackType],
67
+ ) -> Literal[False]:
68
+ self._waiter.wait_until(self._next_end_of_signal_µs)
69
+ return False
qrss_ei/tx.py ADDED
@@ -0,0 +1,93 @@
1
+ import abc
2
+ import contextlib
3
+ import socket
4
+ import typing
5
+
6
+
7
+ class AbstractConnectedTx(abc.ABC):
8
+ @abc.abstractmethod
9
+ def ptt_down(self) -> None:
10
+ pass
11
+
12
+ @abc.abstractmethod
13
+ def ptt_up(self) -> None:
14
+ pass
15
+
16
+ @abc.abstractmethod
17
+ def set_qrg(self, qrg: float) -> None:
18
+ pass
19
+
20
+
21
+ class ConnectedSocket(typing.Protocol):
22
+ def recv(self, bufsize: int) -> bytes:
23
+ pass
24
+
25
+ def sendall(self, b: bytes) -> None:
26
+ pass
27
+
28
+
29
+ class CommunicationWithRigctldError(Exception):
30
+ pass
31
+
32
+
33
+ class ConnectedTx(AbstractConnectedTx):
34
+ def __init__(self, rigctld_socket: ConnectedSocket) -> None:
35
+ self._rigctld_socket = rigctld_socket
36
+
37
+ def _ptt(self, digit: bytes) -> None:
38
+ self._rigctld_socket.sendall(b";\\set_ptt " + digit + b"\n")
39
+ answer = self._rigctld_socket.recv(1024)
40
+ answer_parts = answer.split(b";")
41
+ if len(answer_parts) != 2:
42
+ raise CommunicationWithRigctldError(
43
+ f'Rigctld did not send expected two ";"-separated parts, found {answer!r}'
44
+ )
45
+ if answer_parts[0] != b"set_ptt: " + digit:
46
+ raise CommunicationWithRigctldError(
47
+ f'Rigctld did not send expected initial "set_ptt: {str(digit, encoding="UTF-8")}", found {answer!r}'
48
+ )
49
+ if answer_parts[1] != b"RPRT 0\n":
50
+ raise CommunicationWithRigctldError(
51
+ f'Rigctld answer did not end with "RPRT 0\\n" as expected, found {answer!r}'
52
+ )
53
+
54
+ def ptt_down(self) -> None:
55
+ # Set PTT to data (3), so no accidental noise is transmitted.
56
+ self._ptt(b"3")
57
+
58
+ def ptt_up(self) -> None:
59
+ self._ptt(b"0")
60
+
61
+ def set_qrg(self, qrg: float) -> None:
62
+ freq_b = f"{qrg}".encode("UTF-8")
63
+ self._rigctld_socket.sendall(b";\\set_freq " + freq_b + b"\n")
64
+ answer = self._rigctld_socket.recv(1024)
65
+ answer_parts = answer.split(b";")
66
+ if len(answer_parts) != 2:
67
+ raise CommunicationWithRigctldError(
68
+ f'Rigctld did not send expected two ";"-separated parts, found {answer!r}'
69
+ )
70
+ if answer_parts[0] != b"set_freq: " + freq_b:
71
+ raise CommunicationWithRigctldError(
72
+ f'Rigctld did not send expected initial "set_freq {str(freq_b, encoding="UTF-8")}", found {answer!r}'
73
+ )
74
+ if answer_parts[1] != b"RPRT 0\n":
75
+ raise CommunicationWithRigctldError(
76
+ f'Rigctld answer did not end with "RPRT 0\\n" as expected, found {answer!r}'
77
+ )
78
+
79
+
80
+ def connect_to_rigctld_factory(
81
+ host: str, port: int
82
+ ) -> typing.Callable[[], typing.ContextManager[ConnectedTx]]:
83
+
84
+ @contextlib.contextmanager
85
+ def connect_to_rigctld() -> typing.Iterator[ConnectedTx]:
86
+ with socket.create_connection((host, port)) as rigctld_connected_socket:
87
+ connected_tx = ConnectedTx(rigctld_connected_socket)
88
+ try:
89
+ yield connected_tx
90
+ finally:
91
+ connected_tx.ptt_up()
92
+
93
+ return connect_to_rigctld
qrss_ei/waiting.py ADDED
@@ -0,0 +1,36 @@
1
+ import abc
2
+ import logging
3
+ import time
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class AbstractWaiter(abc.ABC):
9
+ @abc.abstractmethod
10
+ def get_now(self) -> int:
11
+ """Return present time in µseconds since epoch"""
12
+ pass
13
+
14
+ @abc.abstractmethod
15
+ def wait_until(self, µseconds_since_epoch: int) -> None:
16
+ """Wait until"""
17
+ pass
18
+
19
+
20
+ class Waiter(AbstractWaiter):
21
+
22
+ def get_now(self) -> int:
23
+ return (time.monotonic_ns() + 500) // 1000
24
+
25
+ def wait_until(self, µseconds_since_epoch: int) -> None:
26
+ now = self.get_now()
27
+ while now < µseconds_since_epoch:
28
+ need_to_sleep = (µseconds_since_epoch - now) * 1e-6
29
+ # logger.debug(
30
+ # "Need to sleep for %.3f seconds, now %d, wake %d",
31
+ # need_to_sleep,
32
+ # now,
33
+ # µseconds_since_epoch,
34
+ # )
35
+ time.sleep(need_to_sleep)
36
+ now = self.get_now()
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: qrss-ei
3
+ Version: 0.0.1
4
+ Summary: A simple QRSS CW (very slow Morse code) transmit software.
5
+ Author-email: "Dr. Andreas Krüger, DJ3EI" <dj3ei@famsik.de>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://codeberg.org/omwb/qrss-ei
8
+ Project-URL: Repository, https://codeberg.org/omwb/qrss-ei
9
+ Project-URL: Issues, https://codeberg.org/omwb/qrss-ei/issues
10
+ Keywords: ham,amateur radio
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Topic :: Communications :: Ham Radio
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: morse2sound~=1.0.7
20
+ Dynamic: license-file
21
+
22
+ # qrss-ei: Software to transmit QRSS CW signals
23
+
24
+ ## Introduction
25
+
26
+ ### What is QRSS CW?
27
+
28
+ Very slow Morse code. In normal Morse code, the beginner's slow speed
29
+ is 60 cpm (characters per minute), which is the same as 12 wpm (words
30
+ per minute). At that speed, the fundamental unit, the "dot length",
31
+ is 100 ms. In QRSS, the dot length is much longer, commonly 3 seconds
32
+ or more. In other words, QRSS is Morse code with a speed of 2 cpm =
33
+ 0.4 wpm or less.
34
+
35
+ Normal CW is on-off-keying. QRSS is commonly sent as
36
+ frequency-shift-keying, with the "key engaged" frequency typically
37
+ being 5 Hz above the "key released" frequency.
38
+
39
+ Finally, typical QRSS receiving software assumes that the QRSS
40
+ transmission is repeated a few times each 10 minutes, preferably with
41
+ exactly the same timing. This is used to "integrate" serveral
42
+ transmissions so they can be fished out of the noise better.
43
+
44
+ QRSS is also very much a QRP mode. Typical transceivers put out a few
45
+ 100 mW or so, some even less.
46
+
47
+ The waterholes for people interested in this mode seems to be
48
+
49
+ - To read up on it: [Scott Harden + Andy
50
+ resources](https://swharden.com/qrss/plus/)
51
+ - To meet others doing it (and read up some more): [QRSS Knights
52
+ mailing list](https://groups.io/g/qrssknights)
53
+
54
+ ### What is this project?
55
+
56
+ This software enables me to transmit QRSS signals.
57
+
58
+ Management summary: This is a programm that talks to
59
+ [rigctld](https://hamlib.sourceforge.net/html/rigctld.1.html) from the
60
+ well-known [hamlib](https://hamlib.github.io/) software package. The
61
+ rig should be set to RTTY mode, which will cause a carrier to be sent
62
+ whenever PTT is pressed. Alternatively, some other such mode can be
63
+ tried (AM and FM would work as well).
64
+
65
+ No actual characters are ever emitted to be sent via RTTY. Also, this
66
+ uses the "data" variety of "PTT", and as now data comes in, AM and FM
67
+ should also send a bare, unmodulated carrier.
68
+
69
+ On each frequency shift, the PTT is briefly released, the frequency
70
+ shifted 5 Hz up or down, and the PTT again pressed.
71
+
72
+ This is strictly a command-line utility. It needs only plain Python
73
+ without any modules that need compilation. It does make use of the
74
+ [morse2sound](https://gitlab.com/4ham/morse2sound) software by the
75
+ same author which already has the code to convert text into Morse code
76
+ characters. But morse2sound is plain Python as well.
77
+
78
+ ### What do I use to *receive* QRSS CW?
79
+
80
+ This software does not help you to receive QRSS CW. To do that, I
81
+ presently use [Lopora](https://www.qsl.net/pa2ohh/11lop.htm).
82
+
83
+ #### Installation hints on that:
84
+
85
+ The Lopora instructions address unconventional installation targets.
86
+ On my plain Python installation on my plain Linux Debian Laptop, I
87
+ found I just needed an active [virtual
88
+ environment](https://docs.python.org/3/library/venv.html#creating-virtual-environments)
89
+ with this pre-installation:
90
+
91
+ ```
92
+ pip install numpy pillow pyaudio
93
+ ```
94
+
95
+ On my Debian Linux Trixie box, I'm using 16 (pipewire) as the "audio
96
+ device for input". I set the input device actually used via the
97
+ standard Gnome configuration.
98
+
99
+ ## Installation
100
+
101
+ ### Plain Python
102
+
103
+ - Construct a plain Python [virtual
104
+ environment](https://docs.python.org/3/library/venv.html#creating-virtual-environments)
105
+ - activate it
106
+ - `pip install qrss-ei
107
+
108
+ ### uv
109
+
110
+ If you use the [uv](https://docs.astral.sh/uv/) Python dependency
111
+ manager:
112
+
113
+ - Construct a uv virtual environment with `uv venv`
114
+ - activate it
115
+ - `uv pip install qrss-ei`
116
+
@@ -0,0 +1,10 @@
1
+ qrss_ei/__init__.py,sha256=pWuvdIQu9ZheNffFcR4xYkFL9XKV0R98tzx_iOQV5wQ,5469
2
+ qrss_ei/qrss_paddle.py,sha256=Wdk-C2FIS5JrCw0LXVf5h47Hz2Pxahxh1AfoeBCanf8,2179
3
+ qrss_ei/tx.py,sha256=74zsjUwxmPUed-A32v8B3Z3xIau9aGUq8j3Qx56cfhU,3057
4
+ qrss_ei/waiting.py,sha256=ST97-ObRzZP-90-DfMTQGja-j-OoOpjvl94KmKCdmzo,955
5
+ qrss_ei-0.0.1.dist-info/licenses/LICENSE,sha256=jwym1r_Ve0TNtRFGPFVocAx13lVuj9VQWAENgEqNiEY,1074
6
+ qrss_ei-0.0.1.dist-info/METADATA,sha256=ew3epLxGhyCSaxaN61o4z5HEV13eFiTj5qdhpOTUrEk,4209
7
+ qrss_ei-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ qrss_ei-0.0.1.dist-info/entry_points.txt,sha256=1-6IfMZC6s5_lbkeb6TA_EK2SKMdJx3wv17ULGVnx9w,41
9
+ qrss_ei-0.0.1.dist-info/top_level.txt,sha256=1rIU2z0WhSNafJ8-A9d1UgaSZRqCGHhuHJepQ72KlRo,8
10
+ qrss_ei-0.0.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
+ qrss_ei = qrss_ei:main
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Dr. Andreas Krüger, DJ3EI
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ “Software”), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ qrss_ei