|
1 | 1 | import argparse |
| 2 | +import json |
2 | 3 | import sys |
3 | | -from typing import List, Tuple, Type, Any, Union |
| 4 | +from typing import List, Tuple, Type, Any, Union, TextIO, cast |
4 | 5 |
|
| 6 | +from pyais.messages import AISJSONEncoder |
5 | 7 | from pyais.stream import ByteStream, TCPConnection, UDPReceiver, BinaryIOStream |
6 | 8 |
|
7 | 9 | SOCKET_OPTIONS: Tuple[str, str] = ('udp', 'tcp') |
|
10 | 12 | INVALID_CHECKSUM_ERROR = 21 |
11 | 13 |
|
12 | 14 |
|
13 | | -def arg_parser() -> argparse.ArgumentParser: |
14 | | - """Create a new ArgumentParser instance that serves as a entry point to the pyais application. |
15 | | - All possible commandline options and parameters must be defined here. |
16 | | - The goal is to create a grep-like interface: |
17 | | - Usage: ais-decode [OPTION]... PATTERNS [FILE]... |
18 | | - """ |
19 | | - main_parser: argparse.ArgumentParser = argparse.ArgumentParser( |
| 15 | +def create_parser() -> argparse.ArgumentParser: |
| 16 | + """Create and configure the argument parser for the AIS decoder application.""" |
| 17 | + parser = argparse.ArgumentParser( |
20 | 18 | prog="ais-decode", |
21 | | - description="AIS message decoding. 100% pure Python." |
22 | | - "Supports AIVDM/AIVDO messages. Supports single messages, files and TCP/UDP sockets.rst.", |
| 19 | + description="Decode NMEA (AIVDM/AIVDO) AIS messages." |
| 20 | + "Supports single messages, files and TCP/UDP sockets.", |
| 21 | + epilog="Examples:\n" |
| 22 | + " ais-decode -f input.txt # Decode from file\n" |
| 23 | + " ais-decode -j < input.txt # Decode from stdin with JSON output\n" |
| 24 | + " ais-decode socket localhost 5000 # Decode from UDP socket\n" |
| 25 | + " ais-decode single '!AIVDM,1,1,,A,13HOI:0P0000VOHLCnHQKwvL05Ip,0*23'\n" |
| 26 | + " nc 153.44.253.27 5631 | ais-decode --json | jq", |
| 27 | + formatter_class=argparse.RawDescriptionHelpFormatter |
23 | 28 | ) |
24 | | - sub_parsers = main_parser.add_subparsers() |
25 | | - |
26 | | - # Modes |
27 | | - # Currently three mutual exclusive modes are supported: TCP/UDP / file / single messages as arguments |
28 | | - # By default the program accepts input from STDIN |
29 | | - # Optional subparsers server as subcommands that handle socket connections and file reading |
30 | | - main_parser.add_argument( |
31 | | - '-f', |
32 | | - '--file', |
| 29 | + |
| 30 | + # Global options |
| 31 | + parser.add_argument( |
| 32 | + '-j', '--json', |
| 33 | + dest="json", |
| 34 | + action='store_true', |
| 35 | + help="Output messages in JSON format" |
| 36 | + ) |
| 37 | + |
| 38 | + parser.add_argument( |
| 39 | + '-o', '--out-file', |
| 40 | + dest="out_file", |
| 41 | + type=argparse.FileType("w"), |
| 42 | + default=sys.stdout, |
| 43 | + help="Output file (default: stdout)" |
| 44 | + ) |
| 45 | + |
| 46 | + # Create subparsers for different input modes |
| 47 | + subparsers = parser.add_subparsers( |
| 48 | + title="Input modes", |
| 49 | + description="Choose input source", |
| 50 | + dest="mode", |
| 51 | + required=False |
| 52 | + ) |
| 53 | + |
| 54 | + # File input mode (also default for stdin) |
| 55 | + parser.add_argument( |
| 56 | + '-f', '--file', |
33 | 57 | dest="in_file", |
34 | | - nargs="?", |
35 | 58 | type=argparse.FileType("rb"), |
36 | | - default=None |
| 59 | + nargs='?', |
| 60 | + help="Input file (default: stdin if no subcommand specified)" |
37 | 61 | ) |
| 62 | + parser.set_defaults(func=decode_from_file) |
38 | 63 |
|
39 | | - main_parser.set_defaults(func=decode_from_file) |
40 | | - |
41 | | - socket_parser = sub_parsers.add_parser('socket') |
| 64 | + # Socket input mode |
| 65 | + socket_parser = subparsers.add_parser( |
| 66 | + 'socket', |
| 67 | + help="Decode from TCP/UDP socket" |
| 68 | + ) |
42 | 69 | socket_parser.add_argument( |
43 | 70 | 'destination', |
44 | | - type=str, |
| 71 | + help="Hostname or IP address" |
45 | 72 | ) |
46 | 73 | socket_parser.add_argument( |
47 | 74 | 'port', |
48 | | - type=int |
| 75 | + type=int, |
| 76 | + help="Port number" |
49 | 77 | ) |
50 | 78 | socket_parser.add_argument( |
51 | | - '-t', |
52 | | - '--type', |
| 79 | + '-t', '--type', |
53 | 80 | default='udp', |
54 | | - nargs='?', |
55 | | - choices=SOCKET_OPTIONS |
| 81 | + choices=SOCKET_OPTIONS, |
| 82 | + help="Socket type (default: udp)" |
56 | 83 | ) |
57 | | - |
58 | 84 | socket_parser.set_defaults(func=decode_from_socket) |
59 | 85 |
|
60 | | - # Optional a single message can be decoded |
61 | | - # This has the highest precedence and will overwrite all other settings |
62 | | - single_msg_parser = sub_parsers.add_parser('single') |
63 | | - single_msg_parser.add_argument( |
| 86 | + # Single message mode |
| 87 | + single_parser = subparsers.add_parser( |
| 88 | + 'single', |
| 89 | + help="Decode single message(s)" |
| 90 | + ) |
| 91 | + single_parser.add_argument( |
64 | 92 | 'messages', |
65 | 93 | nargs='+', |
66 | | - default=[] |
| 94 | + help="One or more NMEA messages to decode" |
67 | 95 | ) |
68 | | - single_msg_parser.set_defaults(func=decode_single) |
| 96 | + single_parser.set_defaults(func=decode_single) |
69 | 97 |
|
70 | | - # Output |
71 | | - # By default the application writes it output to STDOUT - but this can be any file |
72 | | - main_parser.add_argument( |
73 | | - "-o", |
74 | | - "--out-file", |
75 | | - dest="out_file", |
76 | | - type=argparse.FileType("w"), |
77 | | - default=sys.stdout |
78 | | - ) |
79 | | - |
80 | | - return main_parser |
| 98 | + return parser |
81 | 99 |
|
82 | 100 |
|
83 | 101 | def print_error(*args: Any, **kwargs: Any) -> None: |
84 | | - """Wrapper around the default print function that writes to STDERR.""" |
85 | | - print(*args, **kwargs, file=sys.stdout) |
| 102 | + """Print error messages to stderr.""" |
| 103 | + print(*args, **kwargs, file=sys.stderr) |
| 104 | + |
| 105 | + |
| 106 | +def output_message(msg: Any, out_file: TextIO, as_json: bool) -> None: |
| 107 | + """Output a decoded message in the requested format.""" |
| 108 | + decoded = msg.decode() |
| 109 | + |
| 110 | + if as_json: |
| 111 | + json.dump(decoded.asdict(), out_file, cls=AISJSONEncoder) |
| 112 | + out_file.write('\n') # Add newline for readability |
| 113 | + else: |
| 114 | + out_file.write(str(decoded) + '\n') |
| 115 | + |
| 116 | + out_file.flush() # Ensure immediate output for streaming scenarios |
86 | 117 |
|
87 | 118 |
|
88 | 119 | def decode_from_socket(args: argparse.Namespace) -> int: |
89 | | - """Connect a socket and start decoding.""" |
90 | | - t: str = args.type |
| 120 | + """Connect to a socket and decode incoming AIS messages.""" |
91 | 121 | stream_cls: Type[Union[UDPReceiver, TCPConnection]] |
92 | | - if t == "udp": |
| 122 | + |
| 123 | + if args.type == "udp": |
93 | 124 | stream_cls = UDPReceiver |
94 | | - elif t == "tcp": |
| 125 | + elif args.type == "tcp": |
95 | 126 | stream_cls = TCPConnection |
96 | 127 | else: |
97 | | - raise ValueError("args.type must be either TCP or UDP.") |
| 128 | + print_error(f"Invalid socket type: {args.type}") |
| 129 | + return 1 |
| 130 | + |
| 131 | + try: |
| 132 | + with stream_cls(args.destination, args.port) as stream: |
| 133 | + print_error(f"Connected to {args.type.upper()} {args.destination}:{args.port}") |
| 134 | + |
| 135 | + for msg in stream: |
| 136 | + try: |
| 137 | + output_message(msg, args.out_file, args.json) |
| 138 | + |
| 139 | + if not msg.is_valid: |
| 140 | + print_error(f"WARNING: Invalid checksum for message: {msg}") |
| 141 | + |
| 142 | + except Exception as e: |
| 143 | + print_error(f"ERROR decoding message: {e}") |
| 144 | + continue |
| 145 | + |
| 146 | + except KeyboardInterrupt: |
| 147 | + print_error("\nConnection closed by user") |
| 148 | + return 0 |
| 149 | + except Exception as e: |
| 150 | + print_error(f"ERROR: Failed to connect to {args.destination}:{args.port} - {e}") |
| 151 | + return 1 |
98 | 152 |
|
99 | | - with stream_cls(args.destination, args.port) as s: |
100 | | - try: |
101 | | - for msg in s: |
102 | | - decoded_message = msg.decode() |
103 | | - print(decoded_message, file=args.out_file) |
104 | | - except KeyboardInterrupt: |
105 | | - # Catch KeyboardInterrupts in order to close the socket and free associated resources |
106 | | - return 0 |
107 | 153 | return 0 |
108 | 154 |
|
109 | 155 |
|
110 | 156 | def decode_single(args: argparse.Namespace) -> int: |
111 | | - """Decode a list of messages.""" |
112 | | - messages: List[str] = args.messages |
113 | | - messages_as_bytes: List[bytes] = [msg.encode() for msg in messages if isinstance(msg, str)] |
114 | | - for msg in ByteStream(messages_as_bytes): |
115 | | - print(msg.decode(), file=args.out_file) |
116 | | - if not msg.is_valid: |
117 | | - print_error("WARNING: Checksum invalid") |
118 | | - return 0 |
| 157 | + """Decode a list of single messages.""" |
| 158 | + messages_as_bytes: List[bytes] = [msg.encode() for msg in args.messages] |
| 159 | + error_count = 0 |
| 160 | + |
| 161 | + for i, msg in enumerate(ByteStream(messages_as_bytes)): |
| 162 | + try: |
| 163 | + output_message(msg, args.out_file, args.json) |
| 164 | + |
| 165 | + if not msg.is_valid: |
| 166 | + print_error(f"WARNING: Invalid checksum for message {i + 1}: {args.messages[i]}") |
| 167 | + error_count += 1 |
| 168 | + |
| 169 | + except Exception as e: |
| 170 | + print_error(f"ERROR decoding message {i + 1}: {e}") |
| 171 | + error_count += 1 |
| 172 | + |
| 173 | + return INVALID_CHECKSUM_ERROR if error_count > 0 else 0 |
119 | 174 |
|
120 | 175 |
|
121 | 176 | def decode_from_file(args: argparse.Namespace) -> int: |
122 | | - """Decode messages from a file-like object.""" |
123 | | - if not args.in_file: |
124 | | - # This is needed, because it is not possible to open STDOUT in binary mode (it is text mode by default) |
125 | | - # Therefore it is None by default and we interact with the buffer directly |
126 | | - file = sys.stdin.buffer |
127 | | - else: |
128 | | - # If the file is not None, then it was opened during argument parsing |
129 | | - file = args.in_file |
| 177 | + """Decode messages from a file or stdin.""" |
| 178 | + # Use stdin buffer if no file specified |
| 179 | + file_obj = sys.stdin.buffer if args.in_file is None else args.in_file |
| 180 | + |
| 181 | + try: |
| 182 | + with BinaryIOStream(file_obj) as stream: |
| 183 | + message_count = 0 |
| 184 | + error_count = 0 |
| 185 | + |
| 186 | + for msg in stream: |
| 187 | + try: |
| 188 | + output_message(msg, args.out_file, args.json) |
| 189 | + message_count += 1 |
| 190 | + |
| 191 | + if not msg.is_valid: |
| 192 | + print_error(f"WARNING: Invalid checksum at line {message_count}") |
| 193 | + error_count += 1 |
| 194 | + |
| 195 | + except Exception as e: |
| 196 | + print_error(f"ERROR decoding message at line {message_count + 1}: {e}") |
| 197 | + error_count += 1 |
| 198 | + continue |
| 199 | + |
| 200 | + except KeyboardInterrupt: |
| 201 | + print_error(f"\nProcessing interrupted. Decoded {message_count} messages.") |
| 202 | + return 0 |
| 203 | + except Exception as e: |
| 204 | + print_error(f"ERROR reading input: {e}") |
| 205 | + return 1 |
| 206 | + |
| 207 | + if args.in_file: |
| 208 | + print_error(f"Processed {message_count} messages ({error_count} errors)") |
130 | 209 |
|
131 | | - with BinaryIOStream(file) as s: |
132 | | - try: |
133 | | - for msg in s: |
134 | | - decoded_message = msg.decode() |
135 | | - print(decoded_message, file=args.out_file) |
136 | | - except KeyboardInterrupt: |
137 | | - # Catch KeyboardInterrupts in order to close the file descriptor and free associated resources |
138 | | - return 0 |
139 | 210 | return 0 |
140 | 211 |
|
141 | 212 |
|
142 | 213 | def main() -> int: |
143 | | - main_parser = arg_parser() |
144 | | - namespace: argparse.Namespace = main_parser.parse_args() |
145 | | - exit_code: int = namespace.func(namespace) |
146 | | - return exit_code |
| 214 | + """Main entry point for the AIS decoder application.""" |
| 215 | + parser = create_parser() |
| 216 | + args = parser.parse_args() |
| 217 | + |
| 218 | + # Validate arguments |
| 219 | + if not hasattr(args, 'func'): |
| 220 | + parser.print_help() |
| 221 | + return 1 |
| 222 | + |
| 223 | + try: |
| 224 | + return cast(int, args.func(args)) |
| 225 | + except Exception as e: |
| 226 | + print_error(f"FATAL ERROR: {e}") |
| 227 | + return 1 |
| 228 | + finally: |
| 229 | + # Ensure output file is properly closed |
| 230 | + if hasattr(args, 'out_file') and args.out_file != sys.stdout: |
| 231 | + args.out_file.close() |
147 | 232 |
|
148 | 233 |
|
149 | 234 | if __name__ == "__main__": |
|
0 commit comments