Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions adapt.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"fmt"
"strconv"
"time"

relay "github.com/SoundMatt/RELAY"
)
Expand Down Expand Up @@ -50,6 +51,7 @@ func (n *linNode) Subscribe(opts ...relay.SubscriberOption) (<-chan relay.Messag
var seq uint64
for f := range frames {
msg := f.ToMessage()
msg.Timestamp = time.Now().UTC()
msg.Seq = seq
seq++
switch cfg.BackPressure {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ module github.com/SoundMatt/go-LIN

go 1.25.0

require github.com/SoundMatt/RELAY v0.1.0 // indirect
require github.com/SoundMatt/RELAY v0.8.0
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
github.com/SoundMatt/RELAY v0.1.0 h1:jp24tUZOPSVRgwInKnEtmAxa2SQbN9znKCyhMwwWVmI=
github.com/SoundMatt/RELAY v0.1.0/go.mod h1:8HXkxErdk4pHaM1de9eopm2S8o1aE7tKYkCBBsHxxcg=
github.com/SoundMatt/RELAY v0.8.0 h1:wIvyvMeLvHtNXuxrs0R+HET+F74yO+FWbMD8UhocezQ=
github.com/SoundMatt/RELAY v0.8.0/go.mod h1:8HXkxErdk4pHaM1de9eopm2S8o1aE7tKYkCBBsHxxcg=
29 changes: 19 additions & 10 deletions lin.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ import (
"errors"
"fmt"
"strconv"
"time"

relay "github.com/SoundMatt/RELAY"
)

// SpecVersion is the RELAY specification version this package implements.
const SpecVersion = "0.2"
const SpecVersion = "0.3"

// LINMaxDataLen is the maximum number of data bytes in a LIN frame payload.
const LINMaxDataLen = 8
Expand Down Expand Up @@ -147,6 +146,12 @@ var (
// ErrPayloadTooLarge is returned when a payload exceeds LINMaxDataLen.
ErrPayloadTooLarge = fmt.Errorf("lin: payload too large: %w", relay.ErrPayloadTooLarge)

// ErrInvalidFrame is returned by ValidateFrame for any structural violation
// (out-of-range ID, empty or oversize data, diagnostic frame with a
// non-classic checksum). It is a protocol-specific sentinel and does not
// wrap a RELAY sentinel (RELAY spec §5.3, §5.4).
ErrInvalidFrame = errors.New("lin: invalid frame")

// ErrNoResponse is returned by MasterBus.SendHeader when no slave has
// registered a response for the requested frame ID.
//
Expand Down Expand Up @@ -267,31 +272,35 @@ func CalcChecksum(pid uint8, data []byte, ct LINChecksumType) uint8 {
//fusa:req REQ-LIN-017
func ValidateFrame(f Frame) error {
if f.ID > LINMaxID {
return fmt.Errorf("lin: frame ID 0x%02X exceeds maximum 0x%02X", f.ID, LINMaxID)
return fmt.Errorf("lin: frame ID 0x%02X exceeds maximum 0x%02X: %w", f.ID, LINMaxID, ErrInvalidFrame)
}
if len(f.Data) == 0 {
return errors.New("lin: frame data must not be empty")
return fmt.Errorf("lin: frame data must not be empty: %w", ErrInvalidFrame)
}
if len(f.Data) > LINMaxDataLen {
return fmt.Errorf("lin: frame data length %d exceeds maximum %d", len(f.Data), LINMaxDataLen)
return fmt.Errorf("lin: frame data length %d exceeds maximum %d: %w", len(f.Data), LINMaxDataLen, ErrInvalidFrame)
}
if (f.ID == LINDiagRequestID || f.ID == LINDiagResponseID) && f.ChecksumType != ClassicChecksum {
return errors.New("lin: diagnostic frames must use classic checksum")
return fmt.Errorf("lin: diagnostic frame 0x%02X must use classic checksum: %w", f.ID, ErrInvalidFrame)
}
return nil
}

// ToMessage converts f to a relay.Message envelope per RELAY spec §15.3.
//
// The conversion is deterministic: Timestamp is left as the zero value so the
// output matches the published golden reference vectors. Callers that need a
// wall-clock timestamp (e.g. the live Adapt subscription path) set it after
// conversion.
func (f Frame) ToMessage() relay.Message {
ct := "classic"
if f.ChecksumType == EnhancedChecksum {
ct = "enhanced"
}
return relay.Message{
Protocol: relay.LIN,
ID: strconv.Itoa(int(f.ID)),
Payload: append([]byte(nil), f.Data...),
Timestamp: time.Now().UTC(),
Protocol: relay.LIN,
ID: strconv.Itoa(int(f.ID)),
Payload: append([]byte(nil), f.Data...),
Meta: map[string]string{
"lin.checksum_type": ct,
"lin.checksum": strconv.Itoa(int(f.Checksum)),
Expand Down
136 changes: 136 additions & 0 deletions relay_vectors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) 2026 Matt Jones. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

// Conformance tests against the RELAY spec v0.3 golden reference vectors
// (spec/vectors/). The fixtures under testdata/relay-vectors/ are verbatim
// copies of the published vectors; they verify ToMessage()/FromMessage() and
// ValidateFrame() against the canonical expected output.

package lin_test

import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"

relay "github.com/SoundMatt/RELAY"
lin "github.com/SoundMatt/go-LIN"
)

type vector struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Kind string `json:"kind"`
Value json.RawMessage `json:"value"`
Message json.RawMessage `json:"message"`
Error string `json:"error"`
}

func loadVector(t *testing.T, path string) vector {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "relay-vectors", path))
if err != nil {
t.Fatalf("read vector %s: %v", path, err)
}
var v vector
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("unmarshal vector %s: %v", path, err)
}
return v
}

// ── Golden frame vector: canonical JSON, ToMessage, FromMessage round-trip ────

func TestVector_linFrame_canonicalJSON(t *testing.T) {
v := loadVector(t, "lin-frame.json")

var f lin.Frame
if err := json.Unmarshal(v.Value, &f); err != nil {
t.Fatalf("unmarshal value into Frame: %v", err)
}

// Re-marshal the Frame and confirm it matches the canonical value byte
// content (schema conformance for the lin.Frame type, spec §15.3).
got, err := json.Marshal(f)
if err != nil {
t.Fatalf("marshal Frame: %v", err)
}
var gotObj, wantObj map[string]any
_ = json.Unmarshal(got, &gotObj)
_ = json.Unmarshal(v.Value, &wantObj)
if !reflect.DeepEqual(gotObj, wantObj) {
t.Errorf("canonical JSON mismatch:\n got %s\n want %s", got, v.Value)
}
}

func TestVector_linFrame_toMessage(t *testing.T) {
v := loadVector(t, "lin-frame.json")

var f lin.Frame
if err := json.Unmarshal(v.Value, &f); err != nil {
t.Fatalf("unmarshal value into Frame: %v", err)
}

var want relay.Message
if err := json.Unmarshal(v.Message, &want); err != nil {
t.Fatalf("unmarshal expected message: %v", err)
}

got := f.ToMessage()
if !reflect.DeepEqual(got, want) {
gj, _ := json.Marshal(got)
t.Errorf("ToMessage mismatch:\n got %s\n want %s", gj, v.Message)
}
}

func TestVector_linFrame_fromMessageRoundTrip(t *testing.T) {
v := loadVector(t, "lin-frame.json")

var want lin.Frame
if err := json.Unmarshal(v.Value, &want); err != nil {
t.Fatalf("unmarshal value into Frame: %v", err)
}

got, err := lin.FromMessage(want.ToMessage())
if err != nil {
t.Fatalf("FromMessage: %v", err)
}
if got.ID != want.ID || got.ChecksumType != want.ChecksumType ||
got.Checksum != want.Checksum || !bytes.Equal(got.Data, want.Data) {
t.Errorf("FromMessage round-trip mismatch: got %+v, want %+v", got, want)
}
}

// ── Error vectors: ValidateFrame must reject with ErrInvalidFrame ─────────────

func TestVector_errorFrames_validateFrame(t *testing.T) {
for _, path := range []string{
"errors/lin-id-overflow.json",
"errors/lin-diagnostic-wrong-checksum.json",
} {
v := loadVector(t, path)
t.Run(v.Name, func(t *testing.T) {
if v.Error != "ErrInvalidFrame" {
t.Fatalf("vector declares error %q, expected ErrInvalidFrame", v.Error)
}
var f lin.Frame
if err := json.Unmarshal(v.Value, &f); err != nil {
t.Fatalf("unmarshal value into Frame: %v", err)
}
err := lin.ValidateFrame(f)
if err == nil {
t.Fatalf("ValidateFrame accepted invalid frame %+v", f)
}
if !errors.Is(err, lin.ErrInvalidFrame) {
t.Errorf("ValidateFrame error = %v; want errors.Is ErrInvalidFrame", err)
}
})
}
}
13 changes: 13 additions & 0 deletions testdata/relay-vectors/errors/lin-diagnostic-wrong-checksum.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "lin-diagnostic-wrong-checksum",
"description": "Diagnostic frames (0x3C master request, 0x3D slave response) must use the Classic checksum (0); Enhanced (1) is invalid",
"type": "lin.Frame",
"kind": "error",
"value": {
"id": 60,
"data": "AQIDBAUGBwg=",
"checksum": 0,
"checksum_type": 1
},
"error": "ErrInvalidFrame"
}
13 changes: 13 additions & 0 deletions testdata/relay-vectors/errors/lin-id-overflow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "lin-id-overflow",
"description": "LIN frame ID exceeds the 6-bit maximum 0x3F",
"type": "lin.Frame",
"kind": "error",
"value": {
"id": 64,
"data": "AQ==",
"checksum": 0,
"checksum_type": 0
},
"error": "ErrInvalidFrame"
}
26 changes: 26 additions & 0 deletions testdata/relay-vectors/lin-frame.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "lin-frame",
"description": "LIN frame, enhanced checksum",
"type": "lin.Frame",
"value": {
"id": 16,
"data": "qrs=",
"checksum": 73,
"checksum_type": 1
},
"message": {
"protocol": 3,
"version": {
"major": 0,
"minor": 0,
"patch": 0
},
"id": "16",
"payload": "qrs=",
"timestamp": "0001-01-01T00:00:00Z",
"meta": {
"lin.checksum": "73",
"lin.checksum_type": "enhanced"
}
}
}
Loading