From 0bf7bd71896843110a3666fc1a497bc9b4a87972 Mon Sep 17 00:00:00 2001 From: Matt Jones <47545907+SoundMatt@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:16:03 -0700 Subject: [PATCH] feat(relay): adopt RELAY spec v0.3 (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump github.com/SoundMatt/RELAY v0.1.0 → v0.8.0 (backward compatible) - SpecVersion "0.2" → "0.3" - Add ErrInvalidFrame sentinel; ValidateFrame now wraps it for every structural violation (ID overflow, empty/oversize data, diagnostic frame with non-classic checksum) per spec §5.3/§5.4. Error vectors lin-id-overflow and lin-diagnostic-wrong-checksum require this. - Make Frame.ToMessage() deterministic: drop time.Now(), leave Timestamp zero so output matches the published golden vector. The live Adapt subscription path now stamps msg.Timestamp at delivery instead. - Vendor the published golden + error vectors under testdata/relay-vectors/ and add conformance tests: canonical JSON, ToMessage, FromMessage round-trip, and ValidateFrame → ErrInvalidFrame for both error vectors. Closes #25 Co-Authored-By: Matt Jones Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com> --- adapt.go | 2 + go.mod | 2 +- go.sum | 4 +- lin.go | 29 ++-- relay_vectors_test.go | 136 ++++++++++++++++++ .../errors/lin-diagnostic-wrong-checksum.json | 13 ++ .../relay-vectors/errors/lin-id-overflow.json | 13 ++ testdata/relay-vectors/lin-frame.json | 26 ++++ 8 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 relay_vectors_test.go create mode 100644 testdata/relay-vectors/errors/lin-diagnostic-wrong-checksum.json create mode 100644 testdata/relay-vectors/errors/lin-id-overflow.json create mode 100644 testdata/relay-vectors/lin-frame.json diff --git a/adapt.go b/adapt.go index 02b11c7..4f44520 100644 --- a/adapt.go +++ b/adapt.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "strconv" + "time" relay "github.com/SoundMatt/RELAY" ) @@ -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 { diff --git a/go.mod b/go.mod index 4df2e1b..1a0dbeb 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 20e41c0..ec2665d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/lin.go b/lin.go index d60cb3b..2a96375 100644 --- a/lin.go +++ b/lin.go @@ -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 @@ -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. // @@ -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)), diff --git a/relay_vectors_test.go b/relay_vectors_test.go new file mode 100644 index 0000000..afaa6a5 --- /dev/null +++ b/relay_vectors_test.go @@ -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) + } + }) + } +} diff --git a/testdata/relay-vectors/errors/lin-diagnostic-wrong-checksum.json b/testdata/relay-vectors/errors/lin-diagnostic-wrong-checksum.json new file mode 100644 index 0000000..8ddab80 --- /dev/null +++ b/testdata/relay-vectors/errors/lin-diagnostic-wrong-checksum.json @@ -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" +} diff --git a/testdata/relay-vectors/errors/lin-id-overflow.json b/testdata/relay-vectors/errors/lin-id-overflow.json new file mode 100644 index 0000000..068d9b9 --- /dev/null +++ b/testdata/relay-vectors/errors/lin-id-overflow.json @@ -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" +} diff --git a/testdata/relay-vectors/lin-frame.json b/testdata/relay-vectors/lin-frame.json new file mode 100644 index 0000000..f5a42ff --- /dev/null +++ b/testdata/relay-vectors/lin-frame.json @@ -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" + } + } +}