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 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")
@@ -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: ...