fastnet2ip 1.0.0__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.
- fastnet2ip/__init__.py +1 -0
- fastnet2ip/__main__.py +153 -0
- fastnet2ip/core/__init__.py +0 -0
- fastnet2ip/core/data_store.py +34 -0
- fastnet2ip/core/display.py +23 -0
- fastnet2ip/core/input.py +57 -0
- fastnet2ip/handlers/__init__.py +0 -0
- fastnet2ip/handlers/base.py +43 -0
- fastnet2ip/handlers/nmea0183.py +379 -0
- fastnet2ip/handlers/nmea2000.py +612 -0
- fastnet2ip-1.0.0.dist-info/METADATA +415 -0
- fastnet2ip-1.0.0.dist-info/RECORD +16 -0
- fastnet2ip-1.0.0.dist-info/WHEEL +5 -0
- fastnet2ip-1.0.0.dist-info/entry_points.txt +2 -0
- fastnet2ip-1.0.0.dist-info/licenses/LICENSE +21 -0
- fastnet2ip-1.0.0.dist-info/top_level.txt +1 -0
fastnet2ip/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
fastnet2ip/__main__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import queue
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import serial
|
|
8
|
+
from fastnet_decoder import FrameBuffer, set_log_level
|
|
9
|
+
|
|
10
|
+
from fastnet2ip.core.data_store import live_data, update_live_data
|
|
11
|
+
from fastnet2ip.core.input import initialize_input_source, read_input_source
|
|
12
|
+
from fastnet2ip.core.display import print_live_data
|
|
13
|
+
from fastnet2ip.handlers.nmea0183 import NMEA0183Handler
|
|
14
|
+
from fastnet2ip.handlers.nmea2000 import NMEA2000Handler
|
|
15
|
+
|
|
16
|
+
_HANDLERS = {
|
|
17
|
+
"nmea0183": NMEA0183Handler,
|
|
18
|
+
"nmea2000": NMEA2000Handler,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_GPS_CHANNELS = frozenset({
|
|
22
|
+
"LatLon",
|
|
23
|
+
"Speed Over Ground",
|
|
24
|
+
"Course Over Ground (True)",
|
|
25
|
+
"Course Over Ground (Mag)",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
_HEADING_CHANNELS = frozenset({
|
|
29
|
+
"Heading",
|
|
30
|
+
"Heading (Raw)",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _drain_frame_queue(fq, handler, udp_socket, ignore_gps=False, ignore_heading=False):
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
frame = fq.get_nowait()
|
|
38
|
+
except queue.Empty:
|
|
39
|
+
break
|
|
40
|
+
if not frame:
|
|
41
|
+
continue
|
|
42
|
+
for channel_name, channel_data in frame.get("values", {}).items():
|
|
43
|
+
if not channel_data:
|
|
44
|
+
continue
|
|
45
|
+
if ignore_gps and channel_name in _GPS_CHANNELS:
|
|
46
|
+
continue
|
|
47
|
+
if ignore_heading and channel_name in _HEADING_CHANNELS:
|
|
48
|
+
continue
|
|
49
|
+
channel_id = channel_data.get("channel_id", "??")
|
|
50
|
+
value = channel_data.get("value")
|
|
51
|
+
display_text = channel_data.get("display_text", "")
|
|
52
|
+
layout = channel_data.get("layout")
|
|
53
|
+
|
|
54
|
+
old_entry = live_data.get(channel_name)
|
|
55
|
+
update_live_data(channel_name, channel_id, value, display_text, layout)
|
|
56
|
+
handler.process_channel(channel_name, old_entry, udp_socket)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_loop(input_source, is_file, handler, udp_socket, show_live_data, ignore_gps=False, ignore_heading=False):
|
|
60
|
+
fb = FrameBuffer()
|
|
61
|
+
last_print = time.monotonic()
|
|
62
|
+
while True:
|
|
63
|
+
data = read_input_source(input_source, is_file)
|
|
64
|
+
if data:
|
|
65
|
+
fb.add_to_buffer(data)
|
|
66
|
+
fb.get_complete_frames()
|
|
67
|
+
_drain_frame_queue(fb.frame_queue, handler, udp_socket, ignore_gps, ignore_heading)
|
|
68
|
+
|
|
69
|
+
handler.tick(udp_socket)
|
|
70
|
+
|
|
71
|
+
if show_live_data and time.monotonic() - last_print >= 1:
|
|
72
|
+
print_live_data(fb)
|
|
73
|
+
last_print = time.monotonic()
|
|
74
|
+
|
|
75
|
+
if is_file and data is None:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
# First pass: resolve --output so the handler can register its own flags.
|
|
81
|
+
pre_parser = argparse.ArgumentParser(add_help=False)
|
|
82
|
+
pre_parser.add_argument("--output", default="nmea0183", choices=list(_HANDLERS))
|
|
83
|
+
pre_args, _ = pre_parser.parse_known_args()
|
|
84
|
+
|
|
85
|
+
handler_class = _HANDLERS[pre_args.output]
|
|
86
|
+
|
|
87
|
+
parser = argparse.ArgumentParser(description="FastNet Protocol Decoder")
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--output", default="nmea0183", choices=list(_HANDLERS),
|
|
90
|
+
help="Output format (default: nmea0183)",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument("--serial", type=str, help="Serial port (e.g., /dev/ttyUSB0)")
|
|
93
|
+
parser.add_argument("--file", type=str, help="Path to hex data file")
|
|
94
|
+
parser.add_argument("--log-level", type=str, default="INFO",
|
|
95
|
+
help="Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)")
|
|
96
|
+
parser.add_argument("--live-data", action="store_true",
|
|
97
|
+
help="Print live channel table to console once per second")
|
|
98
|
+
parser.add_argument("--ignore-gps", action="store_true",
|
|
99
|
+
help="Suppress GPS channels (LatLon, COG, SOG) — use when GPS is "
|
|
100
|
+
"already on the network to avoid duplicate/looping data")
|
|
101
|
+
parser.add_argument("--ignore-heading", action="store_true",
|
|
102
|
+
help="Suppress heading channels (Heading, Heading (Raw)) — use when "
|
|
103
|
+
"a compass is already on the network to avoid duplicate/looping data")
|
|
104
|
+
parser.add_argument("--host", type=str, default="255.255.255.255",
|
|
105
|
+
help="UDP destination host (default: 255.255.255.255)")
|
|
106
|
+
parser.add_argument("--udp-port", type=int, default=None,
|
|
107
|
+
help="UDP port (default: 2002 for nmea0183, 2000 for nmea2000)")
|
|
108
|
+
|
|
109
|
+
handler_class.add_arguments(parser)
|
|
110
|
+
args = parser.parse_args()
|
|
111
|
+
|
|
112
|
+
if args.udp_port is None:
|
|
113
|
+
args.udp_port = 2002 if args.output == "nmea0183" else 2000
|
|
114
|
+
|
|
115
|
+
set_log_level(args.log_level)
|
|
116
|
+
logging.getLogger("fastnet2ip").setLevel(
|
|
117
|
+
getattr(logging, args.log_level.upper(), logging.INFO)
|
|
118
|
+
)
|
|
119
|
+
logging.getLogger("fastnet2ip.handlers.nmea2000").setLevel(
|
|
120
|
+
getattr(logging, args.log_level.upper(), logging.INFO)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if args.ignore_gps:
|
|
124
|
+
from fastnet_decoder import logger
|
|
125
|
+
logger.info(f"GPS suppressed: {', '.join(sorted(_GPS_CHANNELS))}")
|
|
126
|
+
|
|
127
|
+
if args.ignore_heading:
|
|
128
|
+
from fastnet_decoder import logger
|
|
129
|
+
logger.info(f"Heading suppressed: {', '.join(sorted(_HEADING_CHANNELS))}")
|
|
130
|
+
|
|
131
|
+
handler = handler_class()
|
|
132
|
+
handler.setup(args)
|
|
133
|
+
|
|
134
|
+
input_source, is_file = initialize_input_source(args)
|
|
135
|
+
|
|
136
|
+
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
137
|
+
udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
138
|
+
|
|
139
|
+
handler.startup(udp_socket)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
run_loop(input_source, is_file, handler, udp_socket, args.live_data, args.ignore_gps, args.ignore_heading)
|
|
143
|
+
except KeyboardInterrupt:
|
|
144
|
+
from fastnet_decoder import logger
|
|
145
|
+
logger.info("Shutting down. Goodbye!")
|
|
146
|
+
finally:
|
|
147
|
+
udp_socket.close()
|
|
148
|
+
if isinstance(input_source, serial.Serial) and input_source.is_open:
|
|
149
|
+
input_source.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
# Single-threaded access only: written and read from the main run loop.
|
|
4
|
+
live_data: dict = {}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def update_live_data(channel_name, channel_id, value, display_text, layout):
|
|
8
|
+
live_data[channel_name] = {
|
|
9
|
+
"channel_id": channel_id,
|
|
10
|
+
"value": value,
|
|
11
|
+
"display_text": display_text,
|
|
12
|
+
"layout": layout,
|
|
13
|
+
"timestamp": datetime.now(timezone.utc),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_live_data(name, as_string=False):
|
|
18
|
+
entry = live_data.get(name)
|
|
19
|
+
if not entry:
|
|
20
|
+
return None
|
|
21
|
+
val = entry.get("value")
|
|
22
|
+
if as_string:
|
|
23
|
+
return str(val) if val is not None else entry.get("display_text")
|
|
24
|
+
return val
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_live_display(name):
|
|
28
|
+
entry = live_data.get(name)
|
|
29
|
+
return entry.get("display_text") if entry else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_live_layout(name):
|
|
33
|
+
entry = live_data.get(name)
|
|
34
|
+
return entry.get("layout") if entry else None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from fastnet2ip.core.data_store import live_data
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def print_live_data(fb):
|
|
7
|
+
print("\033c", end="")
|
|
8
|
+
now = datetime.now(timezone.utc)
|
|
9
|
+
hdr = f"{'Channel':<35} {'ID':<10} {'Value':<20} {'Layout':<12} {'Age(s)':<10}"
|
|
10
|
+
print(hdr)
|
|
11
|
+
print("-" * len(hdr))
|
|
12
|
+
for name, data in sorted(live_data.items()):
|
|
13
|
+
ts = data.get("timestamp")
|
|
14
|
+
val = data.get("value")
|
|
15
|
+
display = str(val) if val is not None else data.get("display_text", "")
|
|
16
|
+
age = f"{(now - ts).total_seconds():.1f}" if ts else ""
|
|
17
|
+
print(
|
|
18
|
+
f"{str(name):<35} {str(data.get('channel_id', '')):<10} "
|
|
19
|
+
f"{display:<20} "
|
|
20
|
+
f"{str(data.get('layout', '')):<12} "
|
|
21
|
+
f"{age:<10}"
|
|
22
|
+
)
|
|
23
|
+
print(f"Buffer: {fb.get_buffer_size()}\n")
|
fastnet2ip/core/input.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import select
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import serial
|
|
5
|
+
|
|
6
|
+
BAUDRATE = 28800
|
|
7
|
+
BYTE_SIZE = serial.EIGHTBITS
|
|
8
|
+
STOP_BITS = serial.STOPBITS_TWO
|
|
9
|
+
PARITY = serial.PARITY_ODD
|
|
10
|
+
READ_SIZE = 256
|
|
11
|
+
FILE_READ_DELAY = 0.05
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def initialize_input_source(args):
|
|
15
|
+
from fastnet_decoder import set_log_level as _sl # avoid circular at module load
|
|
16
|
+
if args.serial:
|
|
17
|
+
from fastnet_decoder import logger
|
|
18
|
+
logger.info(f"Serial port: {args.serial}")
|
|
19
|
+
try:
|
|
20
|
+
return serial.Serial(
|
|
21
|
+
port=args.serial, baudrate=BAUDRATE, bytesize=BYTE_SIZE,
|
|
22
|
+
stopbits=STOP_BITS, parity=PARITY, timeout=0,
|
|
23
|
+
), False
|
|
24
|
+
except (serial.SerialException, OSError) as e:
|
|
25
|
+
logger.error(f"Cannot open {args.serial}: {e}")
|
|
26
|
+
raise SystemExit(1)
|
|
27
|
+
elif args.file:
|
|
28
|
+
from fastnet_decoder import logger
|
|
29
|
+
logger.info(f"File: {args.file}")
|
|
30
|
+
try:
|
|
31
|
+
with open(args.file) as f:
|
|
32
|
+
hex_data = f.read().strip().replace(" ", "")
|
|
33
|
+
if not hex_data:
|
|
34
|
+
raise ValueError("File is empty")
|
|
35
|
+
binary = bytes.fromhex(hex_data)
|
|
36
|
+
except (OSError, ValueError) as e:
|
|
37
|
+
logger.error(f"File error: {e}")
|
|
38
|
+
raise SystemExit(1)
|
|
39
|
+
return iter([binary[i:i + READ_SIZE] for i in range(0, len(binary), READ_SIZE)]), True
|
|
40
|
+
else:
|
|
41
|
+
from fastnet_decoder import logger
|
|
42
|
+
logger.error("Specify --serial or --file")
|
|
43
|
+
raise SystemExit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_input_source(input_source, is_file):
|
|
47
|
+
if is_file:
|
|
48
|
+
try:
|
|
49
|
+
time.sleep(FILE_READ_DELAY)
|
|
50
|
+
return next(input_source)
|
|
51
|
+
except StopIteration:
|
|
52
|
+
return None
|
|
53
|
+
else:
|
|
54
|
+
rlist, _, _ = select.select([input_source], [], [], 1)
|
|
55
|
+
if input_source in rlist:
|
|
56
|
+
return input_source.read(READ_SIZE)
|
|
57
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import socket
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OutputHandler(ABC):
|
|
7
|
+
|
|
8
|
+
@classmethod
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def add_arguments(cls, parser: argparse.ArgumentParser) -> None:
|
|
11
|
+
"""Register format-specific CLI flags on the shared parser."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def setup(self, args: argparse.Namespace) -> None:
|
|
15
|
+
"""Called once after arg parsing. Configure state from args."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def startup(self, udp_socket: socket.socket) -> None:
|
|
19
|
+
"""Called once before the main loop. Send any startup messages."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def process_channel(
|
|
23
|
+
self,
|
|
24
|
+
channel_name: str,
|
|
25
|
+
old_entry: dict | None,
|
|
26
|
+
udp_socket: socket.socket,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Encode and transmit output for one updated channel.
|
|
29
|
+
|
|
30
|
+
Called after live_data has been updated. The handler owns all
|
|
31
|
+
rate-limit and value-change logic.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def tick(self, udp_socket: socket.socket) -> None:
|
|
35
|
+
"""Called once per run_loop iteration. Override for periodic tasks."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def udp_host(self) -> str: ...
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def udp_port(self) -> int: ...
|