Skip to content
This repository was archived by the owner on Dec 25, 2025. It is now read-only.

Commit 1ba658c

Browse files
committed
Added commandline argument -q to add QSO(s) and --stdin to read QSO(s) from stdin
1 parent dfccd6c commit 1ba658c

4 files changed

Lines changed: 357 additions & 204 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,27 @@ To leave the event mode for the following QSOs type a single `$` followed by a S
202202
For xOTA just enter one of SOTA, POTA e.g. `$pota` instead of the contest ID.
203203
Then set your own xOTA reference with `-Nxx-999` and track the QSO partners reference with `%xx-999`.
204204

205+
Commandline support
206+
-------------------
207+
HamCC is also able to import QSOs from STDIN.
208+
209+
# echo -e "8 s df1asc 'Andreas 20241105d\n4 f df1asc 1202d" | hamcc --stdin
210+
211+
or put the QSOs in a file (one QSO per line)
212+
213+
e.g. `qsos.txt`
214+
215+
8 s df1asc 'Andreas 20241105d
216+
4 f df1asc 1202d
217+
218+
Commandline invokation
219+
220+
# hamcc --stdin < qsos.txt
221+
222+
Another possibility is to use argument `-q` per QSO
223+
224+
# hamcc -q 8 s df1asc 'Andreas 20241105d -q 4 f df1asc 1202d
225+
205226
Source Code
206227
-----------
207228
The source code is available at [GitHub](https://github.com/gitandy/HamCC)

src/hamcc/__main__.py

Lines changed: 93 additions & 204 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,51 @@
1+
# Copyright 2024 by Andreas Schawo, licensed under CC BY-SA 4.0
2+
13
"""Log Ham Radio QSOs via console"""
24

35
import os
46
import sys
5-
import time
67
import logging
7-
from platform import python_implementation
8-
from curses import wrapper, error
9-
if python_implementation() == 'PyPy':
10-
# noinspection PyUnresolvedReferences,PyPep8Naming
11-
from curses import Window as window
12-
else:
13-
# noinspection PyUnresolvedReferences
14-
from curses import window
8+
from collections.abc import Iterator
9+
from typing import TextIO
1510

11+
LOG_FMT = '%(asctime)s %(levelname)-8s %(name)s: %(message)s'
1612
logger = logging.getLogger('HamCC')
17-
logging.basicConfig(filename='./hamcc.log', filemode='w', format='%(asctime)s %(levelname)-8s %(name)s: %(message)s',
13+
logging.basicConfig(filename='./hamcc.log', filemode='w', format=LOG_FMT,
1814
level=logging.INFO)
1915

2016
from adif_file import adi
2117
from adif_file import __version_str__ as __version_adif_file__
2218

2319
from . import __proj_name__, __version_str__, __author_name__, __copyright__
24-
from .hamcc import CassiopeiaConsole, adif_date2iso, adif_time2iso
25-
26-
PROMPT = 'QSO> '
27-
LN_MYDATA = 0
28-
LN_QSODATA = 1
29-
LN_INPUT = 2
30-
LN_INFO = 3
31-
32-
33-
def qso2str(qso, pos, cnt) -> tuple[str, str]:
34-
d = adif_date2iso(qso["QSO_DATE"])
35-
t = adif_time2iso(qso["TIME_ON"])
36-
37-
opt_info = ''
38-
for i, f in (
39-
('.', 'RST_RCVD'),
40-
(',', 'RST_SENT'),
41-
('\'', 'NAME'),
42-
('f', 'FREQ'),
43-
('p', 'TX_PWR'),
44-
('*', 'QSL_RCVD'),
45-
('#', 'COMMENT'),
46-
):
47-
if f in qso:
48-
val = qso[f]
49-
if f == 'FREQ':
50-
val = f'{float(val) * 1000:0.3f}'.rstrip('0').rstrip('.')
51-
if f in ('FREQ', 'TX_PWR'):
52-
opt_info += f'| {val} {i} '
53-
else:
54-
opt_info += f'| {i} {val} '
20+
from .hamcc import CassiopeiaConsole
5521

56-
event_info = ''
57-
if 'CONTEST_ID' in qso and qso["CONTEST_ID"]:
58-
event_info = (f'[ $ {qso["CONTEST_ID"]} | -N {qso.get("STX", qso["STX_STRING"])} | '
59-
f'% {qso.get("SRX", qso.get("SRX_STRING", ""))} ]')
60-
elif 'MY_SIG' in qso:
61-
event_info = f'[ $ {qso["MY_SIG"]} | -N {qso["MY_SIG_INFO"]} | % {qso.get("SIG_INFO", "")} ]'
6222

63-
loc = ''
64-
if 'GRIDSQUARE' in qso:
65-
loc = f'{qso["QTH"]} ({qso["GRIDSQUARE"]})' if 'QTH' in qso else qso["GRIDSQUARE"]
23+
def qso_iterator(qso_stream: TextIO) -> Iterator:
24+
line = qso_stream.readline().strip()
25+
if line:
26+
yield line
6627

67-
my_loc = ''
68-
if 'MY_GRIDSQUARE' in qso:
69-
my_loc = f'{qso["MY_CITY"]} ({qso["MY_GRIDSQUARE"]})' if 'MY_CITY' in qso else qso["MY_GRIDSQUARE"]
28+
while line:
29+
line = qso_stream.readline().strip()
30+
if line:
31+
yield line
7032

71-
line1 = (f'[ {"*" if pos == -1 else pos + 1}/{"-" if cnt == 0 else cnt} ] '
72-
f'[ -c {qso["STATION_CALLSIGN"]} | -l {my_loc} | -n {qso.get("MY_NAME", "")} ] {event_info}')
73-
line2 = (f'[ {d} d | {t} t | {qso["BAND"] if qso["BAND"] else "Band"} | {qso["MODE"] if qso["MODE"] else "Mode"} | '
74-
f'{qso["CALL"] if qso["CALL"] else "Call"} | @ {loc} {opt_info}]')
7533

76-
return line1, line2
34+
def process_qsos(qsos: list[list[str]] | TextIO, file: str,
35+
own_call: str, own_loc: str, own_name: str, append: bool = False, # noqa: C901
36+
contest_id: str = '', qso_number: int = 1):
37+
"""Process a list of text input from stdin or commandline as it was typed in console"""
7738

78-
79-
def read_adi(file: str) -> tuple[dict[str, str], dict[str, tuple[str, str]]]:
80-
last_qso = {}
81-
worked_calls = {}
82-
83-
doc = adi.load(file)
84-
for r in doc['RECORDS']:
85-
if all(f in r for f in ('CALL', 'QSO_DATE', 'TIME_ON')):
86-
last_qso = r
87-
worked_calls[r['CALL']] = (r['QSO_DATE'], r['TIME_ON'])
88-
return last_qso, worked_calls
89-
90-
91-
def command_console(stdscr: window, file, own_call, own_loc, own_name, append=False, # noqa: C901
92-
contest_id='', qso_number=1, records: list = []):
9339
adi_f = None
9440
try:
9541
fmode = 'a' if append else 'w'
9642
fexists = os.path.isfile(file)
9743

9844
last_qso = {}
99-
worked_calls = []
10045
if fexists and append:
101-
logger.info('Loading last QSO and worked before...')
102-
last_qso, worked_calls = read_adi(file)
46+
logger.info('Loading last QSO...')
47+
doc = adi.load(file)
48+
last_qso = doc['RECORDS'][-1] if doc['RECORDS'] else {}
10349

10450
adi_f = open(file, fmode)
10551

@@ -115,118 +61,47 @@ def command_console(stdscr: window, file, own_call, own_loc, own_name, append=Fa
11561
adi_f.flush()
11662
logger.info('...done')
11763

118-
if records:
119-
last_qso = records[-1]
120-
121-
cc = CassiopeiaConsole(own_call, own_loc, own_name, contest_id, qso_number, last_qso, worked_calls)
122-
if records:
123-
logger.info('Loading QSOs...')
124-
for r in records:
125-
cc.append_qso(r)
126-
logger.info(f'...done {len(cc.qsos)} QSOs')
127-
128-
# Clear screen
129-
stdscr.clear()
130-
ln1, ln2 = qso2str(cc.current_qso, cc.edit_pos, 0)
131-
stdscr.addstr(LN_MYDATA, 0, ln1)
132-
stdscr.addstr(LN_QSODATA, 0, ln2)
133-
134-
fname = '...' + adi_f.name[-40:] if len(adi_f.name) > 40 else adi_f.name
135-
last_qso_str = (f'. Last QSO: {last_qso["CALL"]} '
136-
f'worked on {adif_date2iso(last_qso["QSO_DATE"])} '
137-
f'at {adif_time2iso(last_qso["TIME_ON"])}') if last_qso and 'CALL' in last_qso else ''
138-
stdscr.addstr(LN_INFO, 0, f'{"Appending to" if append else "Overwriting"} "{fname}"{last_qso_str}')
139-
stdscr.addstr(LN_INPUT, 0, PROMPT)
140-
141-
stdscr.refresh()
142-
stdscr.nodelay(True)
143-
144-
try:
145-
logger.info('Entering main loop...')
146-
while True:
147-
py, px = stdscr.getyx()
148-
ln1, ln2 = qso2str(cc.current_qso, cc.edit_pos, len(cc.qsos))
149-
stdscr.addstr(LN_MYDATA, 0, ln1)
150-
stdscr.clrtoeol()
151-
stdscr.addstr(LN_QSODATA, 0, ln2)
152-
stdscr.clrtoeol()
153-
stdscr.addstr(py, px, '')
154-
155-
while True:
156-
try:
157-
c = stdscr.getkey()
158-
break
159-
except error:
160-
time.sleep(.01)
64+
cc = CassiopeiaConsole(own_call, own_loc, own_name, contest_id, qso_number, last_qso)
65+
qsos = qsos if type(qsos) is list else qso_iterator(qsos)
66+
for qso in qsos:
67+
if type(qso) is str:
68+
qso = qso.strip().split(' ')
69+
logger.info(f'Processing QSO: {qso}')
70+
for chunk in qso:
71+
for char in chunk:
72+
cc.append_char(char)
73+
res = cc.append_char(' ')
74+
if res:
75+
if res.startswith('Warning:'):
76+
logger.warning(f'{res} for "{chunk}"')
77+
elif res.startswith('Error:'):
78+
logger.error(f'{res} for "{chunk}"')
79+
else:
80+
logger.info(f'Processing QSO: {qso}')
81+
for val in qso:
82+
res = cc.evaluate(val)
83+
if res:
84+
if res.startswith('Warning:'):
85+
logger.warning(f'{res} for "{val}"')
86+
elif res.startswith('Error:'):
87+
logger.error(f'{res} for "{val}"')
88+
89+
res = cc.finalize_qso()
90+
if res:
91+
if res.startswith('Warning:'):
92+
logger.warning(res)
93+
elif res.startswith('Error:'):
94+
logger.error(res)
95+
else:
96+
logger.info(res)
16197

162-
if c == 'KEY_UP':
163-
cc.load_prev()
164-
stdscr.addstr(LN_INFO, 0, '')
165-
stdscr.clrtoeol()
166-
stdscr.addstr(LN_INPUT, 0, PROMPT)
167-
stdscr.clrtoeol()
168-
elif c == 'KEY_DOWN':
169-
cc.load_next()
170-
stdscr.addstr(LN_INFO, 0, '')
171-
stdscr.clrtoeol()
172-
stdscr.addstr(LN_INPUT, 0, PROMPT)
173-
stdscr.clrtoeol()
174-
elif c == 'KEY_DC':
175-
res = cc.del_selected()
176-
if res >= 0:
177-
stdscr.addstr(LN_INFO, 0, f'Deleted QSO #{res + 1}')
178-
else:
179-
stdscr.addstr(LN_INFO, 0, '')
180-
stdscr.clrtoeol()
181-
stdscr.addstr(LN_INPUT, 0, PROMPT)
182-
stdscr.clrtoeol()
183-
elif len(c) > 1 or c in '\r\t':
184-
continue
185-
elif c == '\n': # Flush QSO to stack
186-
res = cc.append_char(c)
187-
stdscr.addstr(LN_INFO, 0, res)
188-
stdscr.clrtoeol()
189-
stdscr.addstr(LN_INPUT, 0, PROMPT)
190-
stdscr.clrtoeol()
191-
elif c == '!': # Write QSOs to disk
192-
cc.append_char('\n')
193-
i = 0
194-
msg = ''
195-
while cc.has_qsos():
196-
adi_f.write('\n\n' + adi.dumps({'RECORDS': [cc.pop_qso()]}))
197-
adi_f.flush()
198-
i += 1
199-
msg = f'{i} QSO(s) written to disk'
200-
stdscr.addstr(LN_INFO, 0, msg)
201-
stdscr.clrtoeol()
202-
stdscr.addstr(LN_INPUT, 0, PROMPT)
203-
stdscr.clrtoeol()
204-
else: # Concat sequence
205-
res = cc.append_char(c)
206-
stdscr.addstr(LN_INFO, 0, res)
207-
stdscr.clrtoeol()
208-
if c in ('~', '?'):
209-
stdscr.addstr(LN_INPUT, 0, PROMPT)
210-
stdscr.clrtoeol()
211-
else:
212-
stdscr.addstr(py, px, '')
213-
if c == '\b': # TODO: Is there a better way?
214-
if res == '\b':
215-
stdscr.addstr(c)
216-
stdscr.clrtoeol()
217-
else:
218-
stdscr.addstr(c)
219-
except KeyboardInterrupt:
220-
logger.info('Received keyboard interrupt')
221-
finally:
222-
logger.info(f'Saving {len(cc.qsos)} QSOs...')
22398
while cc.has_qsos():
99+
logger.info(f'Saving {len(cc.qsos)} QSO(s)...')
224100
adi_f.write('\n\n' + adi.dumps({'RECORDS': [cc.pop_qso()]}))
225101
adi_f.flush()
226-
logger.info('...done')
227-
except Exception as exc: # Print exception info due to curses wrapper removes traceback
228-
print(f'{type(exc).__name__}: {exc}', file=sys.stderr)
229-
logger.exception(exc)
102+
logger.info('...done')
103+
except KeyboardInterrupt:
104+
logger.info('Received keyboard interrupt')
230105
finally:
231106
if adi_f:
232107
adi_f.close()
@@ -235,8 +110,6 @@ def command_console(stdscr: window, file, own_call, own_loc, own_name, append=Fa
235110

236111
def main():
237112
import argparse
238-
from datetime import datetime
239-
logger.info('Starting...')
240113

241114
parser = argparse.ArgumentParser(description='Log Ham Radio QSOs via console',
242115
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -248,6 +121,10 @@ def main():
248121
parser.add_argument('file', metavar='ADIF_FILE', nargs='?',
249122
default=os.path.expanduser('~/hamcc_log.adi'),
250123
help='the file to store the QSOs')
124+
parser.add_argument('-q', '--qso', dest='qso', metavar='VALUE', nargs='+', action='append',
125+
help='a QSO string to import instead of running the console (argument can be used repeatedly per QSO)')
126+
parser.add_argument('--stdin', dest='stdin', action='store_true',
127+
help='read QSO strings from STDIN instead of running the console')
251128
parser.add_argument('-c', '--call', dest='own_call', default='',
252129
help='your callsign')
253130
parser.add_argument('-l', '--locator', dest='own_loc', default='',
@@ -272,24 +149,36 @@ def main():
272149
logger.setLevel(args.log_level)
273150
logging.getLogger('hamcc').setLevel(args.log_level)
274151

275-
if os.name == 'nt':
276-
os.system("mode con cols=120 lines=25")
277-
278-
records = []
279-
if args.load_qsos:
280-
if os.path.isfile(args.file):
281-
bak_date = datetime.now().strftime('%Y-%m-%d_%H.%M.%S')
282-
phead, ptail = os.path.split(args.file)
283-
bak_file = os.path.join(phead, f'{bak_date}_{ptail}')
284-
logger.info(f'Creating backup "{bak_file}" from "{args.file}"...')
285-
os.rename(args.file, bak_file)
286-
doc = adi.load(bak_file)
287-
records = doc['RECORDS']
288-
289-
wrapper(command_console, args.file, args.own_call, args.own_loc, args.own_name,
290-
not args.overwrite, args.event, args.exchange, records)
291-
292-
logger.info('Stopped')
152+
if args.qso or args.stdin:
153+
stderr_handler = logging.StreamHandler()
154+
stderr_handler.setFormatter(logging.Formatter(LOG_FMT))
155+
stderr_handler.setLevel(args.log_level)
156+
logger.addHandler(stderr_handler)
157+
logging.getLogger('hamcc').addHandler(stderr_handler)
158+
159+
qsos = sys.stdin if args.stdin else args.qso
160+
process_qsos(qsos, args.file, args.own_call, args.own_loc, args.own_name,
161+
not args.overwrite, args.event, args.exchange)
162+
else:
163+
from datetime import datetime
164+
from ._console_ import run_console
165+
logger.info('Starting console...')
166+
167+
records = []
168+
if args.load_qsos:
169+
if os.path.isfile(args.file):
170+
bak_date = datetime.now().strftime('%Y-%m-%d_%H.%M.%S')
171+
phead, ptail = os.path.split(args.file)
172+
bak_file = os.path.join(phead, f'{bak_date}_{ptail}')
173+
logger.info(f'Creating backup "{bak_file}" from "{args.file}"...')
174+
os.rename(args.file, bak_file)
175+
doc = adi.load(bak_file)
176+
records = doc['RECORDS']
177+
178+
run_console(args.file, args.own_call, args.own_loc, args.own_name,
179+
args.overwrite, args.event, args.exchange, records)
180+
181+
logger.info('Stopped console')
293182

294183

295184
if __name__ == '__main__':

0 commit comments

Comments
 (0)