Skip to content

Commit e816a2a

Browse files
committed
feat: adds --json to ais-decode
1 parent 68cc2a3 commit e816a2a

File tree

5 files changed

+2201
-103
lines changed

5 files changed

+2201
-103
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ ignore = E501
44
show_source = True
55
statistics = True
66
count = True
7-
exclude = .direnv,.git,__pycache__,venv,.eggs
7+
exclude = .direnv,.git,__pycache__,venv,.eggs,.venv

pyais/main.py

Lines changed: 176 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import argparse
2+
import json
23
import sys
3-
from typing import List, Tuple, Type, Any, Union
4+
from typing import List, Tuple, Type, Any, Union, TextIO, cast
45

6+
from pyais.messages import AISJSONEncoder
57
from pyais.stream import ByteStream, TCPConnection, UDPReceiver, BinaryIOStream
68

79
SOCKET_OPTIONS: Tuple[str, str] = ('udp', 'tcp')
@@ -10,140 +12,223 @@
1012
INVALID_CHECKSUM_ERROR = 21
1113

1214

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(
2018
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
2328
)
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',
3357
dest="in_file",
34-
nargs="?",
3558
type=argparse.FileType("rb"),
36-
default=None
59+
nargs='?',
60+
help="Input file (default: stdin if no subcommand specified)"
3761
)
62+
parser.set_defaults(func=decode_from_file)
3863

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+
)
4269
socket_parser.add_argument(
4370
'destination',
44-
type=str,
71+
help="Hostname or IP address"
4572
)
4673
socket_parser.add_argument(
4774
'port',
48-
type=int
75+
type=int,
76+
help="Port number"
4977
)
5078
socket_parser.add_argument(
51-
'-t',
52-
'--type',
79+
'-t', '--type',
5380
default='udp',
54-
nargs='?',
55-
choices=SOCKET_OPTIONS
81+
choices=SOCKET_OPTIONS,
82+
help="Socket type (default: udp)"
5683
)
57-
5884
socket_parser.set_defaults(func=decode_from_socket)
5985

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(
6492
'messages',
6593
nargs='+',
66-
default=[]
94+
help="One or more NMEA messages to decode"
6795
)
68-
single_msg_parser.set_defaults(func=decode_single)
96+
single_parser.set_defaults(func=decode_single)
6997

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
8199

82100

83101
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
86117

87118

88119
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."""
91121
stream_cls: Type[Union[UDPReceiver, TCPConnection]]
92-
if t == "udp":
122+
123+
if args.type == "udp":
93124
stream_cls = UDPReceiver
94-
elif t == "tcp":
125+
elif args.type == "tcp":
95126
stream_cls = TCPConnection
96127
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
98152

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
107153
return 0
108154

109155

110156
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
119174

120175

121176
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)")
130209

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
139210
return 0
140211

141212

142213
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()
147232

148233

149234
if __name__ == "__main__":

pyais/messages.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,13 @@ def bit_field(width: int, d_type: typing.Type[typing.Any],
6767
ENUM_FIELDS = {'status', 'maneuver', 'epfd', 'ship_type', 'aid_type', 'station_type', 'txrx', 'interval'}
6868

6969

70-
class JSONEncoder(json.JSONEncoder):
70+
class AISJSONEncoder(json.JSONEncoder):
7171
"""Custom JSON encoder to handle bytes objects"""
7272

73-
def default(self, obj: typing.Any) -> typing.Any:
74-
if isinstance(obj, bytes):
75-
return b64encode_str(obj)
76-
return json.JSONEncoder.default(self, obj)
73+
def default(self, o: typing.Any) -> typing.Any:
74+
if isinstance(o, bytes):
75+
return b64encode_str(o)
76+
return json.JSONEncoder.default(self, o)
7777

7878

7979
class NMEASentenceFactory:
@@ -817,7 +817,7 @@ def asdict(self, enum_as_int: bool = False) -> typing.Dict[str, typing.Optional[
817817
return {slt: getattr(self, slt) for slt in self.__slots__} # type: ignore
818818

819819
def to_json(self) -> str:
820-
return JSONEncoder(indent=4).encode(self.asdict())
820+
return AISJSONEncoder(indent=4).encode(self.asdict())
821821

822822

823823
#

0 commit comments

Comments
 (0)