n2k

package module
v0.0.0-...-74b8857 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 2, 2026 License: MIT Imports: 22 Imported by: 0

README

n2k

Test Lint Secure

n2k is a Go library for reading and writing NMEA 2000 marine network messages from CAN bus hardware into strongly-typed Go structs.

Installation

go get github.com/open-ships/n2k

Using n2k

Reading and Writing

Client provides read and write access to NMEA 2000. Use it when you need to transmit messages in addition to receiving them.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

client, err := n2k.NewClient(ctx,
    n2k.CAN("can0"),
    n2k.WithSourceAddress(42),
)
if err != nil {
    panic(err)
}
defer client.Close()

// Write a message — the struct knows its own PGN number.
// Priority defaults to 6, destination defaults to broadcast (255).
h := float32(1.5708)
heading := &pgn.VesselHeading{
    Heading: &h,
}
result := client.Write(heading)
if err := result.Wait(); err != nil {
    log.Printf("write failed: %v", err)
}

// Explicitly set priority and destination
heading2 := &pgn.VesselHeading{
    Info:    pgn.MessageInfo{Priority: pgn.Priority(2), TargetId: pgn.Target(42)},
    Heading: &h,
}
client.Write(heading2)

// Read messages (same as top-level API)
for msg, err := range client.Receive() {
    if err != nil {
        panic(err)
    }
    fmt.Printf("Msg: %v\n", msg)
}
Address Claiming

Every device that transmits on NMEA 2000 must claim a unique bus address (1–253) using the ISO 11783 address claim protocol (PGN 60928). NewClient handles this automatically — it broadcasts an address claim, waits for contention, and only returns once a valid address is secured.

How contention works: Each device has a 64-bit NAME. When two devices claim the same address, the lower NAME wins and keeps the address; the loser must yield. The client supports two modes:

// Auto mode (default) — starts at address 253 and negotiates downward on
// contention. If all addresses are exhausted, NewClient returns an error.
client, err := n2k.NewClient(ctx, n2k.CAN("can0"))

// Explicit mode — uses a fixed address. If another device with a lower NAME
// contests it, NewClient returns an error instead of retrying.
client, err := n2k.NewClient(ctx,
    n2k.CAN("can0"),
    n2k.WithSourceAddress(42),
)

Device NAME: The NAME determines who wins contention. Lower NAME = higher priority. Customize it to control your device's identity and arbitration priority on the bus:

client, err := n2k.NewClient(ctx,
    n2k.CAN("can0"),
    n2k.WithName(n2k.DeviceName{
        IndustryGroup:    4,     // 3 bits: 4 = Marine
        ManufacturerCode: 2000,  // 11 bits: unassigned/experimental range
        DeviceClass:      25,    // 7 bits: 25 = Internetwork Device
        DeviceFunction:   130,   // 8 bits: 130 = PC Gateway
        DeviceInstance:   0,     // 8 bits
        SystemInstance:   0,     // 4 bits
        IdentityNumber:   12345, // 21 bits: unique per physical device
    }),
)

When WithName is not set, DefaultDeviceName() is used — it randomizes the identity number so multiple clients from the same binary can coexist on one bus.

Claim timeout: NewClient blocks for up to 1500ms (the default) to allow the network to respond to the initial claim. On heavily contested buses, increase it:

client, err := n2k.NewClient(ctx,
    n2k.CAN("can0"),
    n2k.WithClaimTimeout(3 * time.Second),
)
Read-only
Iterator API:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

for msg, err := range n2k.Receive(ctx, n2k.CAN("can0")) {
    if err != nil {
        panic(err)
    }
    fmt.Printf("Msg: %v\n", msg)
}
Scanner API:
s := n2k.NewScanner(ctx, n2k.CAN("can0"))
for s.Next() {
    fmt.Printf("Msg: %v\n", msg)
}
if err := s.Err(); err != nil {
    ...
}
Multiple Networks

Read from multiple CAN interfaces simultaneously:

for msg, err := range n2k.Receive(ctx,
    n2k.CAN("can0"),
    n2k.CAN("can1"),
    n2k.USB("/dev/ttyUSB0"),
) {
    // messages from all sources, interleaved by arrival
}
Filter Messages using Common Expression Language

Filter messages using CEL expressions.

n2k automatically optimizes filters for max performance -- metadata-only expressions skip decoding entirely.

// Only vessel heading messages
for msg, err := range n2k.Receive(ctx,
    n2k.CAN("can0"),
    n2k.Filter(`pgn == 127250`),
) { ... }

// Filter on decoded fields
for msg, err := range n2k.Receive(ctx,
    n2k.CAN("can0"),
    n2k.Filter(`pgn == 127250 && msg.Heading > 3.14`),
) { ... }

// Filter by source address
for msg, err := range n2k.Receive(ctx,
    n2k.CAN("can0"),
    n2k.Filter(`source == 3`),
) { ... }

Filter variables:

Variable Type Description
pgn int Parameter Group Number
source int Source address (0-252)
priority int Message priority (0-7)
destination int Destination address (255 = broadcast)
msg.<field> varies Decoded struct field (case-insensitive)
Options
Option Description
n2k.CAN(iface) SocketCAN source (e.g., "can0")
n2k.USB(port) USB-CAN serial source (e.g., "/dev/ttyUSB0")
n2k.Replay(frames) Replay source for testing
n2k.Filter(expr) CEL filter expression
n2k.IncludeUnknown() Include undecodable messages as *pgn.UnknownPGN
n2k.WithLogger(l) Override default slog.Logger
n2k.WithSourceAddress(addr) Explicit source address for writes (contention is fatal)
n2k.WithName(name) ISO 11783 device NAME for address claiming
Testing with Replay
frames := []can.Frame{
    {ID: 0x09F10D01, Length: 8, Data: [8]uint8{1, 2, 3, 4, 5, 6, 7, 8}},
}

for msg, err := range n2k.Receive(ctx, n2k.Replay(frames)) {
    // test your message handling
}

PGN Types

All decoded messages implement the pgn.Message interface and are pointers to typed structs in the pgn package. Use a type switch to handle specific message types. PGN structs are organized across category files — system.go, navigation.go, engine.go, etc.

type Message interface {
    PGNNumber() uint32
}

Every struct carries a pgn.MessageInfo field with wire metadata:

type MessageInfo struct {
    Timestamp time.Time
    Priority  *uint8
    PGN       uint32
    SourceId  uint8
    TargetId  *uint8
}

When writing, Priority and TargetId default to 6 and 255 respectively when nil. When reading, they are populated from the wire. Use the helpers pgn.Priority(v) and pgn.Target(v) for concise literal construction:

info := pgn.MessageInfo{
    PGN:      126996,
    SourceId:  3,
    Priority: pgn.Priority(2),
    TargetId: pgn.Target(42),
}

Unit Types

Physical quantities use type-safe wrappers from the units package with built-in conversion methods.

Sniffer CLI

Print decoded NMEA 2000 messages as JSON:

# Read from SocketCAN
go run ./cmd/sniffer.go -i can0

# Read from USB-CAN
go run ./cmd/sniffer.go -u /dev/ttyUSB0

# With CEL filter
go run ./cmd/sniffer.go -i can0 -f 'pgn == 127250'

# Include unknown PGNs
go run ./cmd/sniffer.go -i can0 -unknown

# Pipe to jq
go run ./cmd/sniffer.go -i can0 | jq .

Known Limitations

  • Cross-field validation is not yet implemented (stubs exist for future work).
  • One physical bus per client.
  • Address claiming uses a 1500ms default timeout; on heavily contested buses, increase via WithClaimTimeout.

License

MIT -- see LICENSE.

Acknowledgments

canboat

The PGN definitions and decoders at the core of this library are generated from the canboat project's open-source NMEA 2000 database. canboat reverse-engineered the NMEA 2000 protocol through network observation and public sources, producing the comprehensive PGN catalog that makes libraries like this one possible. For deeper understanding of NMEA 2000 message semantics, field definitions, and manufacturer-specific PGNs, refer to the canboat documentation.

boatkit-io/n2k

This project is inspired by boatkit-io/n2k.

Documentation

Overview

Package n2k decodes NMEA 2000 marine network messages from CAN bus hardware into strongly-typed Go structs.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Receive

func Receive(ctx context.Context, opts ...Option) iter.Seq2[pgn.Message, error]

Receive returns an iterator of decoded NMEA 2000 messages from the configured sources. Each yielded value is a pointer to a typed PGN struct (e.g., *pgn.VesselHeading) or *pgn.UnknownPGN if IncludeUnknown() is set.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the central integration point for NMEA 2000 communication. It composes address claiming, transport protocol, encoding, and framing into a single type that can both read and write PGN messages.

func NewClient

func NewClient(ctx context.Context, opts ...Option) (*Client, error)

NewClient creates a Client that can read and write NMEA 2000 messages. At least one source option (CAN, USB, Replay, or Bus) must be provided.

func (*Client) Close

func (c *Client) Close() error

Close shuts down the client, cancels the context, and releases resources. It is safe to call Close multiple times.

func (*Client) Receive

func (c *Client) Receive() iter.Seq2[pgn.Message, error]

Receive returns an iterator of decoded NMEA 2000 messages. For bus clients it reads from the internal message channel; for replay clients it delegates to the top-level Receive function.

func (*Client) Scanner

func (c *Client) Scanner() *Scanner

Scanner creates a new Scanner that reads from this client. For bus clients it reads from the internal message channel; for replay clients it delegates to NewScanner.

func (*Client) Write

func (c *Client) Write(msg pgn.Message) *WriteResult

Write asynchronously encodes and transmits a PGN message. The message must be a pointer to a PGN struct (e.g. *pgn.VesselHeading). The returned WriteResult can be used to wait for completion and check for errors. Writes are serialized through a single goroutine to guarantee FIFO ordering.

func (*Client) WrittenFrames

func (c *Client) WrittenFrames() []can.Frame

WrittenFrames returns a copy of all CAN frames written through this client. This is primarily useful in replay/testing mode to inspect what was sent.

type DeviceName

type DeviceName struct {
	IdentityNumber   uint32 // 21 bits (0-20)
	ManufacturerCode uint16 // 11 bits (21-31)
	DeviceInstance   uint8  // 8 bits: lower 3 (32-34) + upper 5 (35-39)
	DeviceFunction   uint8  // 8 bits (40-47)
	DeviceClass      uint8  // 7 bits (49-55), bit 48 is reserved
	SystemInstance   uint8  // 4 bits (56-59)
	IndustryGroup    uint8  // 3 bits (60-62)
}

DeviceName represents the ISO 11783 / NMEA 2000 64-bit NAME that uniquely identifies a device on a CAN bus network. The NAME is used during address claiming (PGN 60928) to arbitrate bus addresses.

func DefaultDeviceName

func DefaultDeviceName() DeviceName

DefaultDeviceName returns a DeviceName pre-configured for a PC-based software gateway operating on an NMEA 2000 marine network. The identity number is randomized so that multiple processes using the same binary can coexist on the same bus without NAME collisions during address claiming.

func UnpackDeviceName

func UnpackDeviceName(name uint64) DeviceName

UnpackDeviceName extracts a DeviceName from the 64-bit NAME integer defined by ISO 11783. The arbitraryAddressCapable flag (bit 63) is not stored in the struct; callers can check it directly via (name >> 63) & 1.

func (DeviceName) Pack

func (d DeviceName) Pack(arbitraryAddressCapable bool) uint64

Pack encodes the DeviceName into the 64-bit NAME integer defined by ISO 11783. The arbitraryAddressCapable flag sets bit 63, indicating the device can participate in dynamic address negotiation.

64-bit NAME layout (LSB-first):

Bits  0-20:  Identity Number (21 bits)
Bits 21-31:  Manufacturer Code (11 bits)
Bits 32-34:  Device Instance Lower (3 bits)
Bits 35-39:  Device Instance Upper (5 bits)
Bits 40-47:  Device Function (8 bits)
Bit  48:     Reserved (0)
Bits 49-55:  Device Class (7 bits)
Bits 56-59:  System Instance (4 bits)
Bits 60-62:  Industry Group (3 bits)
Bit  63:     Arbitrary Address Capable (1 bit)

func (DeviceName) Validate

func (d DeviceName) Validate() error

Validate checks that each field fits within its bit width as defined by the ISO 11783 NAME specification. DeviceInstance and DeviceFunction are uint8 and always fit their 8-bit fields.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option configures the behavior of Receive and NewScanner.

func CAN

func CAN(iface string) Option

CAN adds a SocketCAN source for the given Linux CAN interface (e.g., "can0").

func Filter

func Filter(expr string) Option

Filter sets a CEL expression to filter messages. The expression is automatically partitioned into pre-decode (metadata) and post-decode (struct field) stages.

func IncludeUnknown

func IncludeUnknown() Option

IncludeUnknown includes undecodable messages as *pgn.UnknownPGN in the output stream. By default, unknown PGNs are dropped and logged at debug level.

func Replay

func Replay(frames []can.Frame) Option

Replay adds a source that replays the given CAN frames. Useful for testing.

func USB

func USB(port string) Option

USB adds a USB-CAN Analyzer source for the given serial port (e.g., "/dev/ttyUSB0").

func WithBus

func WithBus(bus canbus.Interface) Option

WithBus provides a pre-constructed canbus.Interface for the client to use. This is primarily for testing with mock buses. When set, the client uses this bus directly instead of constructing one from CAN/USB sources.

func WithClaimTimeout

func WithClaimTimeout(d time.Duration) Option

WithClaimTimeout sets how long NewClient blocks waiting for address claiming to complete on a real CAN bus. Default is 1500ms. This allows time for the initial 250ms claim window plus several rounds of contention renegotiation.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger overrides the default slog.Default() logger.

func WithName

func WithName(name DeviceName) Option

WithName sets the ISO 11783 device NAME used for address claiming. The NAME is a 64-bit identifier that uniquely identifies this device on the NMEA 2000 network. In address contention, the device with the lower NAME wins. When not set, a default NAME is used (see DefaultDeviceName).

func WithSourceAddress

func WithSourceAddress(addr uint8) Option

WithSourceAddress sets an explicit NMEA 2000 source address for the client. When set, the client uses this address and treats contention as a fatal error. When not set (default), the client uses auto mode — starting at address 253 and working downward if contention occurs.

type Scanner

type Scanner struct {
	// contains filtered or unexported fields
}

Scanner reads decoded NMEA 2000 messages one at a time. Call Next() to advance, Message() to get the current message, and Err() for errors.

func NewScanner

func NewScanner(ctx context.Context, opts ...Option) *Scanner

NewScanner creates a Scanner that reads from the configured sources.

func (*Scanner) Err

func (s *Scanner) Err() error

Err returns the first error encountered by the scanner.

func (*Scanner) Message

func (s *Scanner) Message() pgn.Message

Message returns the most recently scanned message.

func (*Scanner) Next

func (s *Scanner) Next() bool

Next advances the scanner to the next message. Returns false when no more messages are available (source exhausted or error occurred). Check Err() after Next returns false.

type WriteResult

type WriteResult struct {
	// contains filtered or unexported fields
}

WriteResult represents the outcome of an asynchronous write operation. The zero value is safe to use and behaves as a completed, successful write.

func (*WriteResult) Done

func (r *WriteResult) Done() <-chan struct{}

Done returns a channel that is closed when the write is complete. On a zero-value WriteResult, Done returns an already-closed channel.

func (*WriteResult) Wait

func (r *WriteResult) Wait() error

Wait blocks until the write is complete, then returns the error (or nil). On a zero-value WriteResult, Wait returns nil immediately.

Directories

Path Synopsis
internal
adapter
Package adapter implements the adapter for raw CAN bus frame endpoints.
Package adapter implements the adapter for raw CAN bus frame endpoints.
canbus
Package canbus is built around the Channel structure, which represents a single canbus channel for sending/receiving CAN frames.
Package canbus is built around the Channel structure, which represents a single canbus channel for sending/receiving CAN frames.
claiming
Package claiming implements the NMEA 2000 / ISO 11783 address claiming protocol (PGN 60928).
Package claiming implements the NMEA 2000 / ISO 11783 address claiming protocol (PGN 60928).
decoder
Package decoder converts input messages to an intermediate (Packet) form, and outputs equivalent golang structs.
Package decoder converts input messages to an intermediate (Packet) form, and outputs equivalent golang structs.
framer
Package framer builds CAN frames from encoded PGN payloads.
Package framer builds CAN frames from encoded PGN payloads.
transport
Package transport implements ISO 11783 Transport Protocol for NMEA 2000 messages that exceed 8 bytes and cannot use fast-packet encoding.
Package transport implements ISO 11783 Transport Protocol for NMEA 2000 messages that exceed 8 bytes and cannot use fast-packet encoding.
Package pgn uses data from canboat.json to convert NMEA 2000 messages to strongly-typed golang data.
Package pgn uses data from canboat.json to convert NMEA 2000 messages to strongly-typed golang data.
Package units provides type-safe unit conversion for physical quantities commonly encountered in marine (NMEA 2000) and general-purpose sensor systems.
Package units provides type-safe unit conversion for physical quantities commonly encountered in marine (NMEA 2000) and general-purpose sensor systems.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL