n2k2ip 0.1.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.
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: n2k2ip
3
+ Version: 0.1.0
4
+ Summary: Bridge an NMEA 2000 (SocketCAN) bus to TCP clients as Yacht Devices RAW (YDRAW)
5
+ Author: ghotihook
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ghotihook/n2k2ip
8
+ Project-URL: Repository, https://github.com/ghotihook/n2k2ip
9
+ Project-URL: Issues, https://github.com/ghotihook/n2k2ip/issues
10
+ Keywords: nmea2000,nmea,socketcan,can,ydraw,yacht,marine,gateway
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: No Input/Output (Daemon)
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Communications
22
+ Classifier: Topic :: System :: Networking
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # n2k2ip
29
+
30
+ Bridge an NMEA 2000 (SocketCAN) bus onto the network: read raw CAN frames from a
31
+ SocketCAN interface and stream them to TCP clients as **Yacht Devices RAW (YDRAW)**
32
+ text — one line per CAN frame. A software equivalent of a Yacht Devices Wi-Fi gateway
33
+ (YDWG-02) in *RAW* mode.
34
+
35
+ ```
36
+ NMEA2000 bus ──(can0)──▶ n2k2ip ──(tcp/1457)──▶ many clients
37
+ (plotters, loggers, OpenCPN, …)
38
+ ```
39
+
40
+ ## Why it exists / design
41
+
42
+ The data is **live**, and stale data is worthless. Everything here serves that:
43
+
44
+ - **Drop-old, never back up.** Each client has a bounded outbound queue. When a client
45
+ can't keep up (a slow or stalled Wi-Fi link), the *oldest* whole lines are dropped so
46
+ it always receives recent frames instead of a growing backlog. Maximum staleness is
47
+ bounded by the `MAX_QUEUE` constant (100 lines) in `n2k2ip.py`.
48
+ - **One slow client can't hurt the others.** All writes are non-blocking; a stalled or
49
+ dead client never blocks the CAN reader or the other clients.
50
+ - **Low latency by default.** `TCP_NODELAY` is set on every client, so each line hits
51
+ the wire immediately (no Nagle batching). Over Wi-Fi this matters — measured median
52
+ latency for this kind of small-message stream is a few ms with NODELAY vs tens of ms
53
+ without.
54
+ - **Lightweight.** Single-threaded, event-driven (`selectors`) — no threads, no
55
+ busy-poll, no dependencies. Pure Python standard library.
56
+
57
+ ## Requirements
58
+
59
+ - Linux with SocketCAN (a `can0`-style interface)
60
+ - Python **3.9+** — **no third-party packages** (stdlib only)
61
+ - A **synchronised host clock** — YDRAW timestamps are taken from the host's UTC
62
+ clock, so make sure NTP is running and disciplined (e.g. `systemd-timesyncd` or
63
+ `chrony`). On a headless/boat box without a network, fit an RTC or expect the
64
+ clock — and therefore the timestamps — to be wrong until time is acquired.
65
+
66
+ ## Install
67
+
68
+ On Raspberry Pi OS (and other Debian 12+ systems) the system Python is
69
+ "externally managed", so use **pipx** — it drops the `n2k2ip` command on your
70
+ `PATH` in its own isolated environment and sidesteps `--break-system-packages`:
71
+
72
+ ```sh
73
+ sudo apt install pipx # once, if you don't have it
74
+ pipx install n2k2ip
75
+ ```
76
+
77
+ There are no third-party dependencies, so plain `pip install n2k2ip` also works
78
+ if you'd rather (add `--break-system-packages`, or install into a virtualenv).
79
+
80
+ Because n2k2ip is a single stdlib-only file, you can also skip installing
81
+ entirely and run it straight from a checkout:
82
+
83
+ ```sh
84
+ python3 n2k2ip.py --channel can0 --port 1457
85
+ ```
86
+
87
+ ## Quick start
88
+
89
+ ```sh
90
+ # bring the CAN interface up at the NMEA2000 bitrate
91
+ sudo ip link set can0 up type can bitrate 250000
92
+
93
+ # serve YDRAW on tcp/1457
94
+ n2k2ip --channel can0 --port 1457
95
+ ```
96
+
97
+ Connect and watch:
98
+
99
+ ```sh
100
+ nc <host> 1457
101
+ # 09:46:57.556 R 0DF50B16 00 E6 05 00 00 FF 7F FF
102
+ # 09:46:57.556 R 00FA8C3E 4C 08 C2 24 7E E8 61 4B
103
+ # ...
104
+ ```
105
+
106
+ ## Usage
107
+
108
+ ```
109
+ n2k2ip [--channel can0] [--port 1457] [--log-level INFO]
110
+ ```
111
+
112
+ (or `python3 n2k2ip.py …` when running from a checkout)
113
+
114
+ | option | default | meaning |
115
+ |---|---|---|
116
+ | `--channel` | `can0` | SocketCAN interface to read |
117
+ | `--port` | `1457` | TCP port to serve YDRAW on |
118
+ | `--log-level` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
119
+
120
+ Per-client buffer depth is the `MAX_QUEUE` constant in `n2k2ip.py` (100 lines). When a
121
+ client falls behind, the oldest lines are dropped so it never lags more than ~100
122
+ frames — roughly 0.2–0.5 s of backlog on a busy bus. Raise it only if you'd rather
123
+ tolerate longer stalls than skip frames.
124
+
125
+ ## Output format (YDRAW)
126
+
127
+ Each line is one CAN frame:
128
+
129
+ ```
130
+ HH:MM:SS.mmm R <8-hex CAN-ID> <space-separated hex data bytes>
131
+ ```
132
+
133
+ Lines are terminated with `\r\n`. Direction is always `R` (received). The
134
+ timestamp is the host clock in **UTC** (`HH:MM:SS.mmm`). The YDRAW spec calls for
135
+ UTC sourced from the NMEA 2000 bus when available; we don't decode the bus, so we
136
+ use the host's UTC clock instead — accurate as long as the host clock is synced
137
+ (e.g. NTP).
138
+
139
+ **Multi-frame (fast-packet) messages are passed through, not reassembled** — and that
140
+ is correct for YDRAW, which is a per-CAN-frame format. Each fast-packet frame is sent
141
+ as its own line with its sequence/frame-counter byte intact; reassembling them into a
142
+ logical PGN is the *consumer's* job (e.g. canboat `analyzer`, OpenCPN, a YDRAW
143
+ decoder). Over a lossy link this means a lost frame loses its whole fast-packet PGN —
144
+ inherent to the format, same as a hardware gateway. For that reason **TCP is preferred
145
+ over UDP/broadcast on Wi-Fi**, where reliability and low jitter matter most.
146
+
147
+ ## Run as a service (systemd)
148
+
149
+ Install it globally with pipx, then drop in the shipped unit:
150
+
151
+ ```sh
152
+ sudo apt install pipx # once, if you don't have it
153
+ sudo pipx install --global n2k2ip # -> /usr/local/bin/n2k2ip
154
+ sudo cp n2k2ip.service /etc/systemd/system/
155
+ # edit the unit if your channel/port differ, or if can0 is brought up elsewhere
156
+ sudo systemctl daemon-reload
157
+ sudo systemctl enable --now n2k2ip.service
158
+ journalctl -u n2k2ip.service -f
159
+ ```
160
+
161
+ Update later with `sudo pipx upgrade --global n2k2ip`. If you'd rather not
162
+ pip-install at all, the single stdlib-only file can just be copied and run
163
+ directly — see the comment header in `n2k2ip.service`.
164
+
165
+ ## Behaviour notes
166
+
167
+ - **CAN reconnect.** If the interface goes down or the controller goes bus-off, the
168
+ reader logs it and reopens the socket with backoff — appropriate for a boat bus that
169
+ may power-cycle.
170
+ - **Error/remote frames** are skipped; only data frames are forwarded.
171
+ - **No filtering.** Every data frame on the bus is forwarded. Add a SocketCAN filter in
172
+ `_open_can` if you need to restrict PGNs.
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,7 @@
1
+ n2k2ip.py,sha256=6vaoDu3GZNjrNA9kbZw3XRu_jD7WjelEai4C_uJlqnY,9988
2
+ n2k2ip-0.1.0.dist-info/licenses/LICENSE,sha256=pC9rM_mLyI9TpNbmm9A-yOZ-EH53oU7FoSIefmiM6ug,1066
3
+ n2k2ip-0.1.0.dist-info/METADATA,sha256=3xtYx09YJlWS_jXIdoibpZIpiNJlkKrmw3M0OECa12w,6844
4
+ n2k2ip-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ n2k2ip-0.1.0.dist-info/entry_points.txt,sha256=IHCqAXHS6vPAf3AuNJsb1wjGpLdYk3oWV1XPT__ycic,39
6
+ n2k2ip-0.1.0.dist-info/top_level.txt,sha256=QEQDTvakroc21J6J1A6Pyv52FtDrt2SHbz0tqgEwo7I,7
7
+ n2k2ip-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ n2k2ip = n2k2ip:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ghotihook
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ n2k2ip
n2k2ip.py ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ """n2k2ip — bridge an NMEA2000 (SocketCAN) bus to TCP clients as Yacht Devices RAW.
3
+
4
+ Reads raw CAN frames from a SocketCAN channel and fans each one out, as a single
5
+ YDRAW line, to every connected TCP client.
6
+
7
+ Designed for *live* data, where stale data is worthless:
8
+ - Each client has a bounded outbound queue. If a client can't keep up (a slow or
9
+ stalled wifi link), the OLDEST whole lines are dropped so it always receives
10
+ recent frames, never a growing backlog. Freshness is bounded by MAX_QUEUE.
11
+ - Writes are non-blocking, so one slow/dead client never stalls the CAN reader or
12
+ the other clients.
13
+ - TCP_NODELAY on every client: each line hits the wire immediately (no Nagle).
14
+
15
+ Single-threaded, event-driven (selectors) — no threads, no busy-poll.
16
+
17
+ python3 n2k2ip.py --channel can0 --port 1457
18
+ """
19
+ import argparse
20
+ import logging
21
+ import selectors
22
+ import socket
23
+ import struct
24
+ import time
25
+ from collections import deque
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ # struct can_frame (Linux): u32 can_id, u8 dlc, 3 pad bytes, 8 data bytes
30
+ CAN_FRAME = struct.Struct("=IB3x8s")
31
+ CAN_RTR_FLAG = 0x40000000 # remote-transmission request
32
+ CAN_ERR_FLAG = 0x20000000 # error frame
33
+ CAN_EFF_MASK = 0x1FFFFFFF # 29-bit extended identifier
34
+
35
+ # Max whole lines buffered per client before the oldest are dropped. Bounds how
36
+ # stale a slow client's data can get; favours freshness over completeness.
37
+ MAX_QUEUE = 100
38
+
39
+ log = logging.getLogger("n2k2ip")
40
+
41
+
42
+ def frame_to_ydraw(can_id: int, data: bytes) -> bytes:
43
+ """Render a CAN frame as one YDRAW line: 'HH:MM:SS.mmm R <8-hex id> <hex bytes>'."""
44
+ now = time.time()
45
+ stamp = time.strftime("%H:%M:%S", time.gmtime(now)) + ".%03d" % int((now % 1) * 1000)
46
+ body = data.hex(" ").upper()
47
+ line = "%s R %08X" % (stamp, can_id)
48
+ return ((line + " " + body) if body else line).encode("ascii") + b"\r\n"
49
+
50
+
51
+ class Client:
52
+ __slots__ = ("sock", "addr", "buf", "dropped")
53
+
54
+ def __init__(self, sock, addr):
55
+ self.sock = sock
56
+ self.addr = "%s:%d" % addr
57
+ self.buf = deque() # pending whole YDRAW lines (bytes); each is atomic
58
+ self.dropped = 0
59
+
60
+
61
+ class CanDown(Exception):
62
+ """The CAN socket errored (interface down / bus-off) and must be reopened."""
63
+
64
+
65
+ class Gateway:
66
+ def __init__(self, channel: str, port: int):
67
+ self.channel = channel
68
+ self.max_queue = MAX_QUEUE
69
+ self.clients: set[Client] = set()
70
+ self.sel = selectors.DefaultSelector()
71
+ self.frames = 0
72
+
73
+ self.listen = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
74
+ self.listen.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
75
+ self.listen.bind(("", port))
76
+ self.listen.listen(16)
77
+ self.listen.setblocking(False)
78
+ self.sel.register(self.listen, selectors.EVENT_READ, "listen")
79
+
80
+ self.can = self._open_can()
81
+ self.sel.register(self.can, selectors.EVENT_READ, "can")
82
+ log.info("listening on tcp/%d, reading %s", port, channel)
83
+
84
+ # ── CAN ────────────────────────────────────────────────────────────────────
85
+ def _open_can(self):
86
+ while True:
87
+ try:
88
+ s = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
89
+ s.bind((self.channel,))
90
+ s.setblocking(False)
91
+ return s
92
+ except OSError as e:
93
+ log.warning("%s not available (%s) — retrying; bring it up: "
94
+ "sudo ip link set %s up type can bitrate 250000",
95
+ self.channel, e, self.channel)
96
+ time.sleep(1.0)
97
+
98
+ def _drain_can(self) -> list:
99
+ """Read every frame currently available; return their YDRAW lines."""
100
+ lines = []
101
+ while True:
102
+ try:
103
+ raw = self.can.recv(16)
104
+ except BlockingIOError:
105
+ break
106
+ except OSError as e:
107
+ raise CanDown(str(e))
108
+ if len(raw) < 16:
109
+ continue
110
+ can_id, dlc, payload = CAN_FRAME.unpack(raw)
111
+ if can_id & (CAN_ERR_FLAG | CAN_RTR_FLAG):
112
+ continue # skip error/remote frames
113
+ lines.append(frame_to_ydraw(can_id & CAN_EFF_MASK, payload[:dlc]))
114
+ self.frames += len(lines)
115
+ return lines
116
+
117
+ # ── clients ────────────────────────────────────────────────────────────────
118
+ def _accept(self):
119
+ try:
120
+ sock, addr = self.listen.accept()
121
+ except OSError:
122
+ return
123
+ sock.setblocking(False)
124
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
125
+ c = Client(sock, addr)
126
+ self.clients.add(c)
127
+ self.sel.register(sock, selectors.EVENT_READ, c)
128
+ log.info("client %s connected (%d total)", c.addr, len(self.clients))
129
+
130
+ def _drop(self, c: Client, reason: str):
131
+ if c not in self.clients:
132
+ return # idempotent: already dropped this batch
133
+ self.clients.discard(c)
134
+ try:
135
+ self.sel.unregister(c.sock)
136
+ except (KeyError, ValueError):
137
+ pass # never registered / fd already closed
138
+ try:
139
+ c.sock.close()
140
+ except OSError:
141
+ pass
142
+ log.info("client %s gone: %s (dropped %d lines; %d total)",
143
+ c.addr, reason, c.dropped, len(self.clients))
144
+
145
+ def _flush(self, c: Client) -> bool:
146
+ """Send as much of the client's queue as the socket accepts without blocking.
147
+ Returns True if fully drained. Raises OSError if the client is dead."""
148
+ while c.buf:
149
+ chunk = c.buf[0]
150
+ try:
151
+ n = c.sock.send(chunk)
152
+ except BlockingIOError:
153
+ return False
154
+ if n < len(chunk):
155
+ c.buf[0] = chunk[n:]
156
+ return False
157
+ c.buf.popleft()
158
+ return True
159
+
160
+ def _want_write(self, c: Client):
161
+ ev = selectors.EVENT_READ | (selectors.EVENT_WRITE if c.buf else 0)
162
+ try:
163
+ self.sel.modify(c.sock, ev, c)
164
+ except (KeyError, ValueError):
165
+ pass
166
+
167
+ def _fan_out(self, lines: list):
168
+ for c in list(self.clients):
169
+ c.buf.extend(lines)
170
+ # Bound staleness: keep only the most recent max_queue lines.
171
+ excess = len(c.buf) - self.max_queue
172
+ for _ in range(excess if excess > 0 else 0):
173
+ c.buf.popleft()
174
+ c.dropped += 1
175
+ try:
176
+ self._flush(c)
177
+ except OSError as e:
178
+ self._drop(c, str(e))
179
+ continue
180
+ self._want_write(c)
181
+
182
+ def _service(self, c: Client, mask):
183
+ if mask & selectors.EVENT_READ:
184
+ # We don't expect input; a readable client means data or a closed peer.
185
+ try:
186
+ if not c.sock.recv(4096):
187
+ self._drop(c, "closed by peer")
188
+ return
189
+ except OSError as e:
190
+ self._drop(c, str(e))
191
+ return
192
+ if mask & selectors.EVENT_WRITE:
193
+ try:
194
+ self._flush(c)
195
+ except OSError as e:
196
+ self._drop(c, str(e))
197
+ return
198
+ self._want_write(c)
199
+
200
+ # ── main loop ────────────────────────────────────────────────────────────────
201
+ def run(self):
202
+ last_stat = time.monotonic()
203
+ while True:
204
+ for key, mask in self.sel.select(timeout=5.0):
205
+ tag = key.data
206
+ if tag == "can":
207
+ try:
208
+ self._fan_out(self._drain_can())
209
+ except CanDown as e:
210
+ log.error("CAN error (%s) — reopening %s", e, self.channel)
211
+ self.sel.unregister(self.can)
212
+ self.can.close()
213
+ self.can = self._open_can()
214
+ self.sel.register(self.can, selectors.EVENT_READ, "can")
215
+ elif tag == "listen":
216
+ self._accept()
217
+ elif tag in self.clients:
218
+ self._service(tag, mask)
219
+ # else: a stale event for a client already dropped earlier in this
220
+ # same select() batch — ignore it.
221
+
222
+ now = time.monotonic()
223
+ if now - last_stat >= 30:
224
+ log.info("stats: %d clients, %d frames forwarded", len(self.clients),
225
+ self.frames)
226
+ last_stat = now
227
+
228
+ def close(self):
229
+ for c in list(self.clients):
230
+ c.sock.close()
231
+ self.listen.close()
232
+ self.can.close()
233
+
234
+
235
+ def main():
236
+ ap = argparse.ArgumentParser(description=__doc__,
237
+ formatter_class=argparse.RawDescriptionHelpFormatter)
238
+ ap.add_argument("--channel", default="can0", help="SocketCAN interface (default: can0)")
239
+ ap.add_argument("--port", type=int, default=1457,
240
+ help="TCP port to serve YDRAW on (default: 1457)")
241
+ ap.add_argument("--log-level", default="INFO",
242
+ choices=("DEBUG", "INFO", "WARNING", "ERROR"))
243
+ ap.add_argument("--version", action="version",
244
+ version=f"n2k2ip {__version__}")
245
+ args = ap.parse_args()
246
+
247
+ logging.basicConfig(level=getattr(logging, args.log_level),
248
+ format="%(asctime)s [n2k2ip] %(levelname)s %(message)s")
249
+ gw = Gateway(args.channel, args.port)
250
+ try:
251
+ gw.run()
252
+ except KeyboardInterrupt:
253
+ log.info("stopping")
254
+ finally:
255
+ gw.close()
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()