1+ # Copyright 2024 by Andreas Schawo, licensed under CC BY-SA 4.0
2+
13"""Log Ham Radio QSOs via console"""
24
35import os
46import sys
5- import time
67import 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'
1612logger = 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
2016from adif_file import adi
2117from adif_file import __version_str__ as __version_adif_file__
2218
2319from . 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
236111def 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
295184if __name__ == '__main__' :
0 commit comments