From 6d96d5dc4f3a329ec78fb13f673eb2a0f8b967ec Mon Sep 17 00:00:00 2001 From: Matt Jones <47545907+SoundMatt@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:31:43 -0700 Subject: [PATCH] =?UTF-8?q?feat:=202026-07=20backlog=20sweep=20=E2=80=94?= =?UTF-8?q?=20RELAY=20v1.11,=20diagnostic/sporadic=20frames,=20CLI/Docker/?= =?UTF-8?q?doc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELAY spec conformance: - Bump github.com/SoundMatt/RELAY to v1.11.0 (go.mod, CI relay pins); relay versions now reports ALIGNED=true instead of drifting on spec_version 1.10 - cmd/go-lin's optional send/subscribe commands now implement the spec §11.2 flag signatures: `send --id --data ` and `subscribe --format json --count N` - Docker: add a go-lin image target/stage carrying the spec §13.5 io.relay.* labels, published as ghcr.io/soundmatt/go-lin (previously only the non-conformant quickstart/lintool images were published) - README documents cmd/go-lin (the RELAY-conformant CLI) as the primary CLI; cmd/lintool is now clearly marked legacy Bug fixes: - virtual.Bus.CloseWithDrain now returns lin.ErrTimeout (and counts undelivered frames into DropCount) when ctx expires before draining completes, instead of silently returning nil; the test that had locked in the wrong behavior now asserts the correct one - lin.FromMessage now wraps ErrInvalidFrame for an out-of-range or unparseable message ID, so errors.Is(err, lin.ErrInvalidFrame) works - master.Node.SetSchedule now accepts an empty schedule, matching the documented lin.MasterBus.SetSchedule contract (Run still fails fast on an empty schedule via the independent REQ-MASTER-009 guard) New capabilities: - Diagnostic frames (LIN 2.x §4.2.3): lin.MasterRequestFrame / lin.SlaveResponseFrame single-frame wire encoding, plus master.Node.Diagnostics driving the master-request/slave-response exchange - Sporadic frames (LIN 2.x §2.3.2.4): master.Node.SetSporadicGroup / SetPending select the highest-priority pending candidate for a shared schedule slot, or skip the slot when nothing is pending - ldf.DB.Encode: write-direction signal encoding (the inverse of Decode) - New stats package: frames/sec, per-ID counters, estimated bus load - go-FuSa CLI pin bumped v0.30.0 -> v0.33.3 (CI + release-artifact workflow); test coverage already exceeds the 85% CI floor on every library package (91-100%) - Runnable Example* functions for the core lin/virtual/master/ldf/stats API surface (pkg.go.dev) Docs: - ROADMAP.md replaced the stale pre-RELAY release plan with the actual v0.1.0-v1.2.0 history and re-baselined open work against the current issue tracker - Added CHANGELOG.md Closes #37 Closes #38 Closes #43 Closes #42 Closes #41 Closes #40 Closes #39 Closes #9 Closes #6 Closes #10 Closes #2 Closes #4 Closes #11 Closes #44 Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 105 ++++++++++++++++- .github/workflows/ci.yml | 12 +- .github/workflows/docker.yml | 16 ++- .github/workflows/release.yml | 2 +- CHANGELOG.md | 75 +++++++++++++ README.md | 79 ++++++++++++- ROADMAP.md | 78 ++++++++----- cmd/go-lin/main.go | 126 ++++++++++++++++----- cmd/go-lin/main_test.go | 119 ++++++++++++++++++++ diagnostic.go | 147 ++++++++++++++++++++++++ diagnostic_test.go | 126 +++++++++++++++++++++ docker/Dockerfile | 20 +++- example_test.go | 69 ++++++++++++ examples/quickstart/main.go | 4 +- go.mod | 2 +- go.sum | 2 + ldf/encode_test.go | 93 +++++++++++++++ ldf/example_test.go | 52 +++++++++ ldf/parser.go | 51 +++++++++ lin.go | 2 +- lin_test.go | 12 ++ master/example_test.go | 66 +++++++++++ master/master.go | 149 ++++++++++++++++++++++-- master/master_test.go | 205 +++++++++++++++++++++++++++++++++- safety/e2e_test.go | 4 +- seooc_test.go | 6 +- stats/example_test.go | 30 +++++ stats/stats.go | 166 +++++++++++++++++++++++++++ stats/stats_test.go | 150 +++++++++++++++++++++++++ virtual/bus.go | 16 ++- virtual/coverage_test.go | 7 +- virtual/example_test.go | 41 +++++++ 32 files changed, 1931 insertions(+), 101 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 diagnostic.go create mode 100644 diagnostic_test.go create mode 100644 example_test.go create mode 100644 ldf/encode_test.go create mode 100644 ldf/example_test.go create mode 100644 master/example_test.go create mode 100644 stats/example_test.go create mode 100644 stats/stats.go create mode 100644 stats/stats_test.go create mode 100644 virtual/example_test.go diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 5cafcc8..451ada9 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -580,10 +580,10 @@ { "id": "REQ-MASTER-010", "level": "LLR", - "title": "SetSchedule rejects empty schedule", - "description": "SetSchedule shall return an error when called with nil or a zero-length slice.", + "title": "SetSchedule accepts an empty schedule", + "description": "SetSchedule shall accept nil or a zero-length slice without error, matching the lin.MasterBus.SetSchedule contract (an empty schedule disables scheduled transmission). The busy-loop hazard of running an empty schedule remains independently mitigated by REQ-MASTER-009, which makes Run fail fast when the active schedule is empty.", "asil": "ASIL-B", - "rationale": "REQ-MASTER-009 precondition enforced at configuration time.", + "rationale": "REQ-MASTER-009 already enforces the safety-relevant precondition at Run time; rejecting an empty slice here as well contradicted the documented lin.MasterBus.SetSchedule contract without adding safety value.", "tags": ["master", "validation"] }, { @@ -613,6 +613,33 @@ "rationale": "A temporarily absent slave must not halt the entire LIN schedule.", "tags": ["master", "resilience"] }, + { + "id": "REQ-MASTER-014", + "level": "LLR", + "title": "Diagnostics drives a master-request/slave-response exchange", + "description": "Diagnostics shall publish req on LINDiagRequestID, trigger its header, then trigger LINDiagResponseID and return the parsed SlaveResponseFrame. It shall return an error without transmitting the response header when publishing or sending the request fails.", + "asil": "ASIL-B", + "rationale": "LIN diagnostic services (LIN 2.x §4.2.3) are the standard path for node configuration and fault reporting; a malformed or partially-sent request must not be followed by a response header that the target never saw.", + "tags": ["master", "diagnostics"] + }, + { + "id": "REQ-MASTER-015", + "level": "LLR", + "title": "SetPending marks a frame ID eligible for sporadic selection", + "description": "SetPending shall record id as pending; the next Run pass over a sporadic slot (SetSporadicGroup) whose candidates include id shall select it if it is the highest-priority pending candidate.", + "asil": "ASIL-B", + "rationale": "LIN 2.x §2.3.2.4: sporadic frame transmission is driven by the application's pending-update state, not a fixed schedule.", + "tags": ["master", "sporadic"] + }, + { + "id": "REQ-MASTER-016", + "level": "LLR", + "title": "Run selects and skips sporadic slots correctly", + "description": "For a schedule slot whose ID is registered via SetSporadicGroup, Run shall transmit the header of the highest-priority pending candidate (clearing its pending flag) or, if no candidate is pending, skip the slot entirely without transmitting a header or invoking OnFrame/OnError.", + "asil": "ASIL-B", + "rationale": "LIN 2.x §2.3.2.4: an empty sporadic slot must remain silent on the bus, and OnError must not report a false failure for a slot that was correctly skipped.", + "tags": ["master", "sporadic"] + }, { "id": "REQ-SLAVE-001", "level": "LLR", @@ -1008,6 +1035,78 @@ "asil": "ASIL-B", "rationale": "ISO 21434 / TARA: the convert driver processes untrusted JSON on stdin; invalid input must fail closed (CWE-20) and not leak a partial conversion.", "tags": ["security", "cyber", "validation", "cli"] + }, + { + "id": "REQ-STATS-001", + "level": "LLR", + "title": "New returns a ready Collector", + "description": "stats.New shall return a non-nil Collector with zeroed counters, ready to Observe frames immediately.", + "asil": "ASIL-A", + "rationale": "SG-03-class observability path: stats is a passive, out-of-band consumer of already-validated frames and has no authority over bus behaviour, so it is rated ASIL-A rather than the ASIL-B rating of the frame-validation/transmission path it observes.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-002", + "level": "LLR", + "title": "Observe updates total and per-ID counters", + "description": "Observe shall increment the total frame count and the counter for f.ID by exactly one per call.", + "asil": "ASIL-A", + "rationale": "Correct counters are required for the observability data to be trustworthy, but a miscount has no effect on bus safety.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-003", + "level": "LLR", + "title": "FrameRate reports a non-negative, finite rate", + "description": "FrameRate shall return 0, not NaN or Inf, when called before any measurable time has elapsed since New or Reset.", + "asil": "ASIL-A", + "rationale": "A NaN/Inf result propagating into a caller's logging or alerting path is a robustness defect, not a safety hazard.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-004", + "level": "LLR", + "title": "BusLoad returns 0 when the bus speed is unknown", + "description": "BusLoad shall return 0 when the Collector was constructed with baudKbps <= 0, instead of dividing by zero.", + "asil": "ASIL-A", + "rationale": "Bus speed is not always known to an observer; BusLoad must degrade gracefully rather than panic or return a meaningless value.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-005", + "level": "LLR", + "title": "PerID returns a defensive copy", + "description": "PerID shall return a copy of the internal per-ID counter map; mutating the returned map shall not affect the Collector's internal state.", + "asil": "ASIL-A", + "rationale": "Matches the defensive-copy convention used throughout the codebase (e.g. ldf.DB.Frames, master.Node.SetSchedule) to prevent aliasing bugs.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-006", + "level": "LLR", + "title": "TotalCount reports the cumulative Observe count", + "description": "TotalCount shall return the number of Observe calls since New or the last Reset.", + "asil": "ASIL-A", + "rationale": "Direct consequence of REQ-STATS-002's counting contract.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-007", + "level": "LLR", + "title": "Reset clears all counters and the rate window", + "description": "Reset shall zero the total count, per-ID counters, and bit-time accumulator, and restart the elapsed-time window used by FrameRate and BusLoad.", + "asil": "ASIL-A", + "rationale": "A caller must be able to start a fresh measurement interval without recreating the Collector.", + "tags": ["stats", "observability"] + }, + { + "id": "REQ-STATS-008", + "level": "LLR", + "title": "Watch observes every frame until the channel closes", + "description": "Watch shall call Observe once per frame received from ch and return when ch is closed.", + "asil": "ASIL-A", + "rationale": "Watch is a convenience wrapper; its only safety-relevant property is that it terminates cleanly on channel close rather than leaking a goroutine.", + "tags": ["stats", "observability"] } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c69aa..d1b700e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: go-version: "1.25" - name: Install relay CLI - run: go install github.com/SoundMatt/RELAY/cmd/relay@v1.10.0 + run: go install github.com/SoundMatt/RELAY/cmd/relay@v1.11.0 - name: Build CLI run: go build -o /tmp/go-lin ./cmd/go-lin @@ -158,7 +158,7 @@ jobs: go-version: "1.25" - name: Install relay CLI - run: go install github.com/SoundMatt/RELAY/cmd/relay@v1.10.0 + run: go install github.com/SoundMatt/RELAY/cmd/relay@v1.11.0 - name: Build CLI run: go build -o /tmp/go-lin ./cmd/go-lin @@ -167,14 +167,14 @@ jobs: run: relay interop --protocol LIN /tmp/go-lin # ── go-FuSa full safety lifecycle (spec §20.1.2) ────────────────────────── - # Pinned to v0.30.0. §20 makes the full lifecycle normative: every change must + # Pinned to v0.33.3. §20 makes the full lifecycle normative: every change must # pass check (ERROR gate), 100% requirement traceability, cybersecurity # analysis, dependency vulnerability scan, and tool qualification. The # remaining steps generate evidence artifacts (verify/fmea/release) and are # non-gating. To upgrade: install the new version locally, run the gating # commands, fix all findings, then bump the @version pin below. gofusa: - name: go-FuSa full lifecycle (v0.30.0) + name: go-FuSa full lifecycle (v0.33.3) runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -184,7 +184,7 @@ jobs: go-version: "1.25" - name: Install go-FuSa - run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@v0.30.0 + run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@v0.33.3 - name: gofusa check (gate on ERROR findings) run: gofusa check ./... @@ -272,7 +272,7 @@ jobs: go-version: "1.25" - name: Install go-FuSa - run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@v0.30.0 + run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@v0.33.3 - name: Generate evidence run: | diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0cc1e20..e747a22 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - target: [quickstart, lintool] + target: [go-lin, quickstart, lintool] steps: - uses: actions/checkout@v5 @@ -40,11 +40,23 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # go-lin is the RELAY-conformant CLI (spec §13.5): it publishes under + # the bare tool name (ghcr.io/soundmatt/go-lin), not a "-go-lin" suffix. + # quickstart/lintool are example/legacy images and keep their suffix. + - name: Resolve image name + id: image + run: | + if [ "${{ matrix.target }}" = "go-lin" ]; then + echo "name=${{ env.IMAGE_BASE }}" >> "$GITHUB_OUTPUT" + else + echo "name=${{ env.IMAGE_BASE }}-${{ matrix.target }}" >> "$GITHUB_OUTPUT" + fi + - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_BASE }}-${{ matrix.target }} + images: ${{ steps.image.outputs.name }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1c32b1..6e7a582 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: go-version: "1.25" - name: Install go-FuSa - run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@v0.30.0 + run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@v0.33.3 - name: Regenerate dFMEA table run: gofusa fmea -cyber diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1ff7eec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to go-LIN are documented here. Versions correspond to +git tags; see `gh release list` / `git tag --sort=-creatordate` for the +canonical list. Dates are release dates (UTC-7, matching tag creation). + +## [Unreleased] + +- fix: `virtual.Bus.CloseWithDrain` now returns `lin.ErrTimeout` (and counts + undelivered frames into `DropCount`) when the context expires before + draining completes, instead of silently returning `nil` (#37) +- fix: `lin.FromMessage` now wraps `lin.ErrInvalidFrame` for an out-of-range + or unparseable message ID, so `errors.Is(err, lin.ErrInvalidFrame)` works (#38) +- fix: `master.Node.SetSchedule` now accepts an empty schedule, matching the + documented `lin.MasterBus.SetSchedule` contract; `master.Node.Run` still + fails fast on an empty schedule (#43) +- feat: `cmd/go-lin`'s optional `send`/`subscribe` commands now implement the + spec §11.2 flag signatures — `send --id --data ` and + `subscribe --format json --count N` (#41) +- feat: `ldf` package gains write-direction signal encoding, `db.Encode(id, + signals) ([]byte, error)` (#6) +- feat: new `stats` package — a `Collector` tracking frames/sec, per-ID + counters, and bus load percentage for observability (#10) +- feat: `lin` package gains typed diagnostic-frame helpers, + `MasterRequestFrame`/`SlaveResponseFrame`, and a `master.Node.Diagnostics` + handler for the master request (0x3C) / slave response (0x3D) exchange (#2) +- feat: `master.Node` gains sporadic-frame slot support — a schedule slot can + now select from a group of candidate frame IDs based on pending-update + flags set by the application (#4) +- docs: add runnable `Example*` functions for the core `lin`, `virtual`, and + `master` API surface (#11) +- docs: README documents `cmd/go-lin` (the RELAY-conformant CLI) as the + primary CLI, with `cmd/lintool` clearly marked legacy (#40) +- docker: add a `go-lin` image target/stage with the spec §13.5 `io.relay.*` + labels, published as `ghcr.io/soundmatt/go-lin` (#39) +- chore: bump `github.com/SoundMatt/RELAY` to v1.11.0 (go.mod, CI) — closes + the `relay versions` ALIGNED=false drift (#42) +- chore: bump the pinned `go-FuSa` CLI from v0.30.0 to v0.33.3 in CI and the + release-artifact-regeneration workflow (#9) +- docs: replace the stale pre-RELAY roadmap with an up-to-date release + history and re-baseline "planned" work against the current issue tracker; + add this CHANGELOG (#44) + +## [1.2.0] — 2026-06-19 + +Full ISO/IEC/DO compliance evidence pack + max coverage (#35). + +## [1.1.0] — 2026-06-19 + +Adopt RELAY spec v1.10: §13.7 cross-language library architecture convention, +§20 continuous conformance (#34). + +## [1.0.0] — 2026-06-17 + +Adopt RELAY spec v1.0 (stable) conformance (#27, #28). + +## [0.4.0] — 2026-06-17 + +Adopt RELAY spec v0.3 conformance (#25, #26); CI fix making short fuzz runs +iteration-based to stop flaky failures (#24). + +## [0.3.0] — 2026-06-16 + +RELAY spec v0.2 conformance: `Subscribe` slice signature and LIN-prefixed +constants (#16, #20); full RELAY v0.2 conformance (#17, #21); optional +interfaces — `HealthProvider`, `MetricsProvider`, `Drainer` (#18, #23). + +## [0.2.0] — 2026-06-13 + +Expand to 100 atomic ASIL-B SEOOC requirements (#15). + +## [0.1.0] — 2026-06-13 + +Initial release: core `lin.Bus`/`MasterBus` interfaces, virtual bus, LDF +parser, master/slave nodes, E2E safety, `cmd/lintool` CLI, Docker quickstart. diff --git a/README.md b/README.md index 80b8337..414769e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ The `lin.Bus` interface is stable. Implementations are swappable without changin | `master` | LIN master node — schedule execution and header transmission. | Nothing | | `slave` | LIN slave node — response registration and frame subscription. | Nothing | | `safety` | E2E protection header — DataID, SourceID, SequenceCounter, CRC-16. | Nothing | -| `cmd/lintool` | CLI tool: `send`, `dump`, `pid`, `cs` subcommands. | Nothing | +| `stats` | Observability: per-second frame rate, per-ID counters, estimated bus load. | Nothing | +| `cmd/go-lin` | RELAY-conformant CLI: `version`, `capabilities`, `status`, `convert`, `send`, `subscribe`, plus `dump`/`pid`/`cs`. Built and gated by CI (`relay conform --strict`, `relay interop`). | Nothing | +| `cmd/lintool` | Legacy example CLI (pre-RELAY): `send`, `dump`, `pid`, `cs` subcommands. Not RELAY-conformant — use `cmd/go-lin` instead. | Nothing | ## Install @@ -64,6 +66,12 @@ docker compose -f docker/docker-compose.yml up --build Runs a single container with a slave goroutine publishing synthetic window-position frames and a master goroutine driving the schedule and printing each received frame. +The RELAY-conformant CLI is also published as an image: + +```bash +docker run --rm ghcr.io/soundmatt/go-lin version +``` + ## LDF parser ```go @@ -73,9 +81,47 @@ db, _ := ldf.Parse(strings.NewReader(ldfContent)) frame := db.Frame(0x10) // frame descriptor sig := db.Signal("EngineSpeed") // signal descriptor vals := db.Decode(0x10, rawData) // map[string]uint64 of raw signal values +raw := db.Encode(0x10, vals) // write direction: pack signal values into a frame payload sched := db.Schedule("NormalSchedule") ``` +## Diagnostic frames + +```go +import lin "github.com/SoundMatt/go-LIN" + +// master.Node.Diagnostics drives the LIN 2.x §4.2.3 master-request (0x3C) / +// slave-response (0x3D) exchange. +req := lin.MasterRequestFrame{NAD: 0x01, SID: 0xB2, Data: []byte{0x01, 0x02}} +resp, err := masterNode.Diagnostics(ctx, req) +``` + +## Sporadic frames + +```go +// master.Node.SetSporadicGroup declares a schedule slot as sporadic +// (LIN 2.x §2.3.2.4): on each pass, the master transmits the header of the +// highest-priority candidate the application has flagged as pending, or +// skips the slot entirely if nothing is pending. +m.SetSporadicGroup(0x30, []uint8{0x11, 0x12}) // 0x11 has priority over 0x12 +m.SetSchedule([]lin.ScheduleEntry{{ID: 0x30, DelayMs: 10}}) +m.SetPending(0x11) // application marks 0x11's data as changed +``` + +## Statistics + +```go +import "github.com/SoundMatt/go-LIN/stats" + +c := stats.New(19.2) // bus speed in kbit/s, for BusLoad +ch, _ := bus.Subscribe(nil) +go c.Watch(ch) +// ... later: +c.FrameRate() // frames/sec +c.BusLoad() // estimated % of bus bandwidth used +c.PerID() // map[uint8]uint64 of per-frame-ID counts +``` + ## Protected Identifier (PID) ```go @@ -107,15 +153,36 @@ original, err := r.Unwrap(protected) // checks CRC, sequence counter The 10-byte E2E header exceeds the standard LIN payload (1–8 bytes). Use with diagnostic frames (0x3C/0x3D) or a multi-slot transport layer. -## CLI tool +## RELAY CLI + +`cmd/go-lin` is the RELAY-conformant CLI: the binary CI builds and gates with +`relay conform --strict` and `relay interop` (spec §11.1/§11.2), and the one +published as `ghcr.io/soundmatt/go-lin` (spec §13.5). ```bash -go run ./cmd/lintool pid 0x10 # compute PID -go run ./cmd/lintool cs 0x10 01020304 # compute enhanced checksum -go run ./cmd/lintool send 0x10 01020304 # publish response + trigger exchange -go run ./cmd/lintool dump # subscribe to all frames +# RELAY mandatory commands (spec §11.1) +go run ./cmd/go-lin version # tool + spec version, JSON or text +go run ./cmd/go-lin capabilities # capabilities document (JSON) +go run ./cmd/go-lin status # self-assessed health + +# RELAY interop driver (spec §11.2) +go run ./cmd/go-lin convert --protocol LIN # lin.Frame JSON (stdin) -> relay.Message JSON (stdout) + +# Crossbar spokes (spec §11.2 optional commands) +go run ./cmd/go-lin send --id 0x10 --data 01020304 # publish response + trigger exchange +go run ./cmd/go-lin send --format json # NDJSON relay.Message sink (stdin) +go run ./cmd/go-lin subscribe --format json --count 5 # NDJSON relay.Message source (stdout), exit after 5 + +# Convenience / debugging +go run ./cmd/go-lin dump # subscribe to all frames until SIGINT +go run ./cmd/go-lin pid 0x10 # compute PID +go run ./cmd/go-lin cs 0x10 01020304 # compute enhanced checksum ``` +`cmd/lintool` is a legacy pre-RELAY example CLI (`send`, `dump`, `pid`, `cs` +only — no `version`/`capabilities`/`status`/`convert`) kept for backward +compatibility. New integrations should use `cmd/go-lin`. + ## Safety & compliance go-LIN is developed as a Safety Element Out Of Context (SEOOC) targeting diff --git a/ROADMAP.md b/ROADMAP.md index fae1229..3a82dde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,25 +26,40 @@ The project focuses on: --- -## Release Plan +## Release History -| Version | Theme | Status | +See [CHANGELOG.md](CHANGELOG.md) for the detailed per-version changelog. Summary: + +| Version | Theme | +|---|---| +| v0.1.0 | Core `lin.Bus`/`MasterBus` interfaces, virtual bus, LDF parser, master/slave nodes, E2E safety, `cmd/lintool` CLI, Docker quickstart | +| v0.2.0 | 100 atomic ASIL-B SEOOC requirements | +| v0.3.0 | RELAY spec v0.2 conformance — `Subscribe` slice signature, optional interfaces (`HealthProvider`, `MetricsProvider`, `Drainer`) | +| v0.4.0 | RELAY spec v0.3 conformance | +| v1.0.0 | RELAY spec v1.0 (stable) conformance | +| v1.1.0 | RELAY spec v1.10 conformance — §13.7 cross-language library architecture, §20 continuous conformance | +| v1.2.0 | Full ISO/IEC/DO compliance evidence pack, max coverage | +| Unreleased | RELAY spec v1.11 conformance; `cmd/go-lin` RELAY CLI docs/Docker image; diagnostic frames, sporadic frames, LDF write-direction encoding, `stats` package; see CHANGELOG | + +--- + +## Planned / Open Work + +Re-baselined against the current issue tracker (2026-07) — this table replaces +the original pre-RELAY plan, which described work later superseded by the +RELAY spec-conformance releases above. + +| Issue | Theme | Status | |---|---|---| -| v0.1.0 | Core `lin.Bus`/`MasterBus` interfaces, virtual bus, LDF parser, master/slave nodes, E2E safety, CLI, Docker quickstart | **next** | -| v0.2.0 | Serial/UART transport (`transport/`) — physical LIN on Linux via `/dev/ttyS*` | planned | -| v0.3.0 | Diagnostic frames — master request (0x3C) and slave response (0x3D) handling | planned | -| v0.4.0 | Sleep/wakeup frame sequences — go-to-sleep command, wakeup pulse | planned | -| v0.5.0 | Sporadic frames — master selects which frame to transmit based on flags | planned | -| v0.6.0 | Event-triggered frames — multi-slave collision resolution | planned | -| v0.7.0 | LDF signal encoding (write direction) and value table support | planned | -| v0.8.0 | go-FuSa v0.30.0 → latest; coverage 80% across all packages | planned | -| v0.9.0 | Statistics — bus load, frame error counters, per-ID metrics | planned | -| v1.0.0 | API stability, full serial transport, documentation complete | planned | -| v1.1.0 | **Bridge — CAN** (`bridge/can/`) — LIN-over-CAN gateway (works with go-CAN) | planned | -| v1.2.0 | **Bridge — MQTT** (`bridge/mqtt/`) — publish/subscribe LIN frames over MQTT | planned | -| v1.3.0 | **Bridge — DDS** (`bridge/dds/`) — LIN frame distribution over DDS topics | planned | -| v1.4.0 | **Bridge — SOME/IP** (`bridge/someip/`) — LIN frames as SOME/IP service events | planned | -| v1.5.0 | **Bridge — gRPC** (`bridge/grpc/`) — stream LIN frames over gRPC | planned | +| [#1](https://github.com/SoundMatt/go-LIN/issues/1) | Serial/UART transport (`transport/`) — physical LIN on Linux via `/dev/ttyS*` | open — hardware-facing; needs a real-hardware or hardware-in-the-loop validation plan before landing in a safety library | +| [#3](https://github.com/SoundMatt/go-LIN/issues/3) | Sleep/wakeup frame sequences — go-to-sleep command, wakeup pulse generation/detection | open — wakeup pulse handling is a physical-transport concern, blocked on #1 | +| [#5](https://github.com/SoundMatt/go-LIN/issues/5) | Event-triggered frames — multi-slave collision resolution | open — needs a dedicated protocol-timing design pass | +| [#7](https://github.com/SoundMatt/go-LIN/issues/7) | **Bridge — CAN** (`bridge/can/`) — LIN-over-CAN gateway (works with go-CAN) | open — cross-repo dependency on go-CAN | +| [#8](https://github.com/SoundMatt/go-LIN/issues/8) | **Bridge — MQTT** (`bridge/mqtt/`) — publish/subscribe LIN frames over MQTT | open — needs an MQTT client dependency, not yet vendored | + +Delivered since the original plan was written (see CHANGELOG for details): +diagnostic frames (#2), sporadic frames (#4), LDF write-direction encoding (#6), +statistics (#10), godoc examples (#11), go-FuSa/coverage (#9). --- @@ -72,6 +87,7 @@ The project focuses on: - Frame definitions (ID, publisher, length, signal-to-bit-offset mappings) - Schedule table parsing (frame name + delay) - Signal decoder: `db.Decode(id, data) map[string]uint64` +- Signal encoder (write direction): `db.Encode(id, signals) []byte` - Fuzz target for `Parse` ### 4 — Master Node @@ -79,6 +95,10 @@ The project focuses on: - `SendHeader` driving `MasterBus` - `OnFrame` and `OnError` callbacks - Context-cancellation support +- `Diagnostics` — LIN 2.x §4.2.3 master-request/slave-response exchange + (`MasterRequestFrame`/`SlaveResponseFrame`) +- Sporadic frame slots — `SetSporadicGroup`/`SetPending`, priority-ordered + candidate selection per LIN 2.x §2.3.2.4 ### 5 — Slave Node - Response registration per frame ID @@ -91,13 +111,21 @@ The project focuses on: - Detects CRC mismatch, sequence gaps, and short headers - Fuzz target for `ProtectUnwrap` -### 7 — CLI (lintool) -- `send ` — publish response and trigger one frame exchange -- `dump` — print all received frames to stdout -- `pid ` — compute and display Protected Identifier -- `cs ` — compute and display enhanced checksum - -### 8 — Docker Quickstart -- Multi-stage Dockerfile (builder → quickstart, builder → lintool) +### 7 — CLI +- `cmd/go-lin` — the RELAY-conformant CLI: `version`/`capabilities`/`status` + (spec §11.1), `convert` (spec §11.2 interop driver), `send`/`subscribe` + (spec §11.2 optional crossbar-spoke commands, including the + `--id`/`--data`/`--count` flag forms), plus `dump`/`pid`/`cs` +- `cmd/lintool` — legacy pre-RELAY example CLI (`send`, `dump`, `pid`, `cs`), + kept for backward compatibility + +### 8 — Docker +- Multi-stage Dockerfile (builder → go-lin, quickstart, lintool) +- `go-lin` image carries the spec §13.5 `io.relay.*` labels, published as + `ghcr.io/soundmatt/go-lin` - docker-compose.yml for zero-config demo - Multi-arch images (linux/amd64, linux/arm64) published to GHCR + +### 9 — Observability +- `stats.Collector` — frames/sec, per-frame-ID counters, estimated bus load + percentage diff --git a/cmd/go-lin/main.go b/cmd/go-lin/main.go index 4cb8513..8d115ff 100644 --- a/cmd/go-lin/main.go +++ b/cmd/go-lin/main.go @@ -13,12 +13,15 @@ // // Protocol commands: // -// send Publish a response for a frame ID and trigger one exchange. -// send --format json Streaming relay.Message NDJSON sink (crossbar spoke, §11.2). -// subscribe --format json Streaming relay.Message NDJSON source (crossbar spoke, §11.2). -// dump Subscribe to all frames and print them to stdout. -// pid Compute and display the Protected Identifier. -// cs Compute and display the enhanced checksum. +// send Publish a response for a frame ID and trigger one exchange. +// send --id --data Same as above; the §11.2 protocol-flag form. +// send --format json Streaming relay.Message NDJSON sink (crossbar spoke, §11.2). +// subscribe --format json [--count N] +// Streaming relay.Message NDJSON source (crossbar spoke, +// §11.2). Exits after N messages when --count is given. +// dump Subscribe to all frames and print them to stdout. +// pid Compute and display the Protected Identifier. +// cs Compute and display the enhanced checksum. // // RELAY interop driver (§11.2): // @@ -96,12 +99,15 @@ RELAY mandatory commands: status [--format text|json] print self-assessed health Protocol commands: - send publish response for and trigger one frame exchange - send --format json streaming relay.Message NDJSON sink (crossbar spoke) - subscribe --format json streaming relay.Message NDJSON source (crossbar spoke) - dump print all frames on the virtual bus until SIGINT - pid compute the Protected Identifier for a 6-bit frame ID - cs compute the enhanced LIN checksum for a frame + send publish response for and trigger one frame exchange + send --id --data same as above, spec §11.2 flag form + send --format json streaming relay.Message NDJSON sink (crossbar spoke) + subscribe --format json [--count N] + streaming relay.Message NDJSON source (crossbar spoke); + exits after N messages when --count is given, else on SIGINT + dump print all frames on the virtual bus until SIGINT + pid compute the Protected Identifier for a 6-bit frame ID + cs compute the enhanced LIN checksum for a frame RELAY interop driver: convert --protocol LIN [--format json] lin.Frame JSON (stdin) -> relay.Message JSON (stdout) @@ -190,16 +196,17 @@ func cmdStatus(args []string, w io.Writer) { func cmdSend(args []string) { // `send --format json` is the streaming NDJSON sink / crossbar spoke (§11.2); - // `send ` is the ad-hoc single-exchange form. + // `send --id --data ` is the spec §11.2 protocol-flag form; + // `send ` is the ad-hoc positional single-exchange form. if flagFormat(args) == "json" { os.Exit(cmdSendStream(os.Stdin, os.Stdout, os.Stderr)) } - if len(args) != 2 { - fmt.Fprintf(os.Stderr, "usage: %s send | send --format json\n", toolName) - os.Exit(1) + + id, data, ok := parseSendArgs(args) + if !ok { + fmt.Fprintf(os.Stderr, "usage: %s send | send --id --data | send --format json\n", toolName) + os.Exit(2) } - id := parseID(args[0]) - data := parseHex(args[1]) bus, err := virtual.New() if err != nil { @@ -266,13 +273,24 @@ func cmdSendStream(stdin io.Reader, w, errw io.Writer) int { // cmdSubscribe is the streaming JSON source (RELAY §11.2 / crossbar spoke). With // `--format json` it subscribes to every frame on the virtual bus and writes each -// as a one-line relay.Message (NDJSON) to stdout until SIGINT. It is the ingress -// dual of `send --format json`. +// as a one-line relay.Message (NDJSON) to stdout until SIGINT, or until --count N +// messages have been written. It is the ingress dual of `send --format json`. func cmdSubscribe(args []string) { if flagFormat(args) != "json" { - fmt.Fprintf(os.Stderr, "usage: %s subscribe --format json\n", toolName) + fmt.Fprintf(os.Stderr, "usage: %s subscribe --format json [--count N]\n", toolName) os.Exit(1) } + + count := -1 // -1 = unbounded, run until SIGINT + if s, ok := flagValue(args, "--count"); ok { + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + fmt.Fprintf(os.Stderr, "%s: invalid --count %q: must be a non-negative integer\n", toolName, s) + os.Exit(2) + } + count = n + } + bus, err := virtual.New() if err != nil { fatal("virtual.New: %v", err) @@ -287,9 +305,21 @@ func cmdSubscribe(args []string) { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - enc := json.NewEncoder(os.Stdout) + runSubscribe(ctx, ch, count, os.Stdout) +} + +// runSubscribe drains ch, writing each frame as a one-line relay.Message +// (NDJSON) to w, until ctx is done, ch closes, or count messages have been +// written (count < 0 means unbounded). It is the testable core of +// cmdSubscribe, split out because cmdSubscribe wires up a real bus and a +// SIGINT/SIGTERM-cancelled context that a unit test cannot easily drive. +func runSubscribe(ctx context.Context, ch <-chan lin.Frame, count int, w io.Writer) { + enc := json.NewEncoder(w) var seq uint64 for { + if count == 0 { + return + } select { case <-ctx.Done(): return @@ -302,6 +332,9 @@ func cmdSubscribe(args []string) { msg.Seq = seq seq++ _ = enc.Encode(msg) // one compact JSON object per line (NDJSON) + if count > 0 { + count-- + } } } } @@ -365,15 +398,33 @@ func cmdConvert(args []string, stdin io.Reader, stdout, stderr io.Writer) int { // flagFormat returns the value of a --format flag (e.g. "json"), or "" if absent. func flagFormat(args []string) string { + v, _ := flagValue(args, "--format") + return v +} + +// flagValue returns the value of a "--name value" or "--name=value" flag and +// whether it was present. +func flagValue(args []string, name string) (string, bool) { for i, a := range args { switch { - case a == "--format" && i+1 < len(args): - return args[i+1] - case strings.HasPrefix(a, "--format="): - return strings.TrimPrefix(a, "--format=") + case a == name && i+1 < len(args): + return args[i+1], true + case strings.HasPrefix(a, name+"="): + return strings.TrimPrefix(a, name+"="), true + } + } + return "", false +} + +// hasFlag reports whether name appears anywhere in args (as "--name" or +// "--name=..."), regardless of whether it takes a value. +func hasFlag(args []string, name string) bool { + for _, a := range args { + if a == name || strings.HasPrefix(a, name+"=") { + return true } } - return "" + return false } func cmdDump() { @@ -435,6 +486,27 @@ func printFrame(f lin.Frame) { f.ID, strings.ToUpper(hex.EncodeToString(f.Data)), f.Checksum) } +// parseSendArgs parses the argument forms accepted by `send` other than +// `--format json` (handled separately by the caller): the spec §11.2 +// `--id --data ` flag form, and the ad-hoc positional +// ` ` form. It reports ok=false, with no side effects, when +// neither form matches so the caller can print usage and exit 2. +func parseSendArgs(args []string) (id uint8, data []byte, ok bool) { + switch { + case hasFlag(args, "--id") || hasFlag(args, "--data"): + idStr, idOK := flagValue(args, "--id") + dataStr, dataOK := flagValue(args, "--data") + if !idOK || !dataOK { + return 0, nil, false + } + return parseID(idStr), parseHex(dataStr), true + case len(args) == 2: + return parseID(args[0]), parseHex(args[1]), true + default: + return 0, nil, false + } +} + func parseID(s string) uint8 { var v uint64 var err error diff --git a/cmd/go-lin/main_test.go b/cmd/go-lin/main_test.go index ed8beb8..ae648a5 100644 --- a/cmd/go-lin/main_test.go +++ b/cmd/go-lin/main_test.go @@ -7,11 +7,13 @@ package main import ( "bytes" + "context" "encoding/json" "strings" "testing" relay "github.com/SoundMatt/RELAY" + lin "github.com/SoundMatt/go-LIN" ) // TestConvert_goldenVector verifies the §11.2 interop driver produces the @@ -202,3 +204,120 @@ func TestConvert_wrongFormat(t *testing.T) { t.Errorf("convert --format yaml exit = %d, want 2", code) } } + +// TestFlagValue and TestHasFlag cover the generic --name/--name=value flag +// helpers added for the spec §11.2 send/subscribe flag forms (issue #41). +func TestFlagValue(t *testing.T) { + cases := []struct { + args []string + name string + wantVal string + wantOK bool + }{ + {[]string{"--id", "16"}, "--id", "16", true}, + {[]string{"--id=16"}, "--id", "16", true}, + {[]string{"--data", "01020304"}, "--data", "01020304", true}, + {[]string{"--id"}, "--id", "", false}, // dangling flag, no value follows + {[]string{}, "--id", "", false}, + {[]string{"--other", "x"}, "--id", "", false}, + } + for _, tc := range cases { + got, ok := flagValue(tc.args, tc.name) + if got != tc.wantVal || ok != tc.wantOK { + t.Errorf("flagValue(%v, %q) = (%q, %v), want (%q, %v)", tc.args, tc.name, got, ok, tc.wantVal, tc.wantOK) + } + } +} + +func TestHasFlag(t *testing.T) { + cases := []struct { + args []string + name string + want bool + }{ + {[]string{"--id", "16"}, "--id", true}, + {[]string{"--id=16"}, "--id", true}, + {[]string{"--data", "01"}, "--id", false}, + {[]string{}, "--id", false}, + } + for _, tc := range cases { + if got := hasFlag(tc.args, tc.name); got != tc.want { + t.Errorf("hasFlag(%v, %q) = %v, want %v", tc.args, tc.name, got, tc.want) + } + } +} + +// TestParseSendArgs_flagForm covers the spec §11.2 `send --id --data +// ` protocol-flag form (issue #41), alongside the pre-existing +// positional form and the invalid-args case. +func TestParseSendArgs_flagForm(t *testing.T) { + id, data, ok := parseSendArgs([]string{"--id", "16", "--data", "01020304"}) + if !ok { + t.Fatal("parseSendArgs(--id/--data): ok = false, want true") + } + if id != 0x10 { + t.Errorf("id = 0x%02X, want 0x10", id) + } + if string(data) != "\x01\x02\x03\x04" { + t.Errorf("data = % X, want 01 02 03 04", data) + } +} + +func TestParseSendArgs_positionalForm(t *testing.T) { + id, data, ok := parseSendArgs([]string{"0x10", "01020304"}) + if !ok { + t.Fatal("parseSendArgs(positional): ok = false, want true") + } + if id != 0x10 || string(data) != "\x01\x02\x03\x04" { + t.Errorf("parseSendArgs(positional) = (0x%02X, % X)", id, data) + } +} + +func TestParseSendArgs_invalid(t *testing.T) { + cases := [][]string{ + {}, + {"only-one-arg"}, + {"--id", "16"}, // --data missing + {"--data", "01"}, // --id missing + } + for _, args := range cases { + if _, _, ok := parseSendArgs(args); ok { + t.Errorf("parseSendArgs(%v): ok = true, want false", args) + } + } +} + +// TestRunSubscribe_count verifies `subscribe --format json --count N` (issue +// #41) exits after N messages rather than running until the channel closes +// or the context is cancelled. +func TestRunSubscribe_count(t *testing.T) { + ch := make(chan lin.Frame, 3) + ch <- lin.Frame{ID: 0x10, Data: []byte{0x01}} + ch <- lin.Frame{ID: 0x11, Data: []byte{0x02}} + ch <- lin.Frame{ID: 0x12, Data: []byte{0x03}} + + var out bytes.Buffer + runSubscribe(context.Background(), ch, 2, &out) + + lines := strings.Count(strings.TrimSpace(out.String()), "\n") + 1 + if lines != 2 { + t.Errorf("runSubscribe(count=2) wrote %d line(s), want 2:\n%s", lines, out.String()) + } +} + +// TestRunSubscribe_unboundedStopsOnClose verifies count < 0 (no --count +// flag) runs until the channel closes. +func TestRunSubscribe_unboundedStopsOnClose(t *testing.T) { + ch := make(chan lin.Frame, 2) + ch <- lin.Frame{ID: 0x10, Data: []byte{0x01}} + ch <- lin.Frame{ID: 0x11, Data: []byte{0x02}} + close(ch) + + var out bytes.Buffer + runSubscribe(context.Background(), ch, -1, &out) + + lines := strings.Count(strings.TrimSpace(out.String()), "\n") + 1 + if lines != 2 { + t.Errorf("runSubscribe(count=-1) wrote %d line(s), want 2:\n%s", lines, out.String()) + } +} diff --git a/diagnostic.go b/diagnostic.go new file mode 100644 index 0000000..ff6ee6b --- /dev/null +++ b/diagnostic.go @@ -0,0 +1,147 @@ +// 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/. + +package lin + +import "fmt" + +// DiagDataLen is the number of application data bytes carried by a single- +// frame LIN diagnostic message, after the NAD, PCI, and SID/RSID bytes +// (LIN 2.x §4.2.3): 8 total frame bytes - 3 header bytes = 5. +const DiagDataLen = 5 + +// diagPadding fills unused diagnostic data bytes, per LIN 2.x §4.2.3. +const diagPadding = 0xFF + +// MasterRequestFrame is the LIN master request diagnostic frame (ID 0x3C, +// LINDiagRequestID). It is always exactly 8 bytes on the wire: NAD, a +// single-frame PCI (length), SID, and up to 5 data bytes padded with 0xFF. +// +// This models the LIN 2.x single-frame diagnostic transport layer +// (LIN 2.x §4.2.3); multi-frame (first-frame/consecutive-frame) transport is +// not covered. +type MasterRequestFrame struct { + // NAD is the Node Address of the target slave (0x01-0x7D individual, + // 0x7E functional/broadcast-to-all-diagnostic-nodes, 0x7F broadcast). + NAD uint8 + + // SID is the application-defined Service Identifier. + SID uint8 + + // Data holds up to DiagDataLen bytes of request data. Len(Data) MUST be + // <= DiagDataLen; ToFrame pads the remainder with 0xFF. + Data []byte +} + +// SlaveResponseFrame is the LIN slave response diagnostic frame (ID 0x3D, +// LINDiagResponseID). Layout mirrors MasterRequestFrame with RSID (the +// response service identifier, conventionally SID+0x40) in place of SID. +type SlaveResponseFrame struct { + // NAD is the Node Address of the responding slave. + NAD uint8 + + // RSID is the Response Service Identifier. + RSID uint8 + + // Data holds up to DiagDataLen bytes of response data. + Data []byte +} + +// ToFrame packs a MasterRequestFrame into the wire-level 8-byte classic- +// checksum lin.Frame transmitted on LINDiagRequestID. +// +//fusa:req REQ-LIN-001 +func (r MasterRequestFrame) ToFrame() (Frame, error) { + data, err := packDiag(r.NAD, r.SID, r.Data) + if err != nil { + return Frame{}, fmt.Errorf("lin: MasterRequestFrame: %w", err) + } + pid := ProtectID(LINDiagRequestID) + return Frame{ + ID: LINDiagRequestID, + Data: data, + Checksum: CalcChecksum(pid, data, ClassicChecksum), + ChecksumType: ClassicChecksum, + }, nil +} + +// ToFrame packs a SlaveResponseFrame into the wire-level 8-byte classic- +// checksum lin.Frame transmitted on LINDiagResponseID. +// +//fusa:req REQ-LIN-001 +func (r SlaveResponseFrame) ToFrame() (Frame, error) { + data, err := packDiag(r.NAD, r.RSID, r.Data) + if err != nil { + return Frame{}, fmt.Errorf("lin: SlaveResponseFrame: %w", err) + } + pid := ProtectID(LINDiagResponseID) + return Frame{ + ID: LINDiagResponseID, + Data: data, + Checksum: CalcChecksum(pid, data, ClassicChecksum), + ChecksumType: ClassicChecksum, + }, nil +} + +// packDiag lays out [NAD, PCI, SID/RSID, data..., 0xFF-padding] as an +// 8-byte diagnostic payload per LIN 2.x §4.2.3. +func packDiag(nad, sidOrRsid uint8, data []byte) ([]byte, error) { + if len(data) > DiagDataLen { + return nil, fmt.Errorf("data length %d exceeds maximum %d: %w", len(data), DiagDataLen, ErrInvalidFrame) + } + out := make([]byte, LINMaxDataLen) + for i := range out { + out[i] = diagPadding + } + out[0] = nad + out[1] = uint8(len(data) + 1) // PCI: single-frame length = SID/RSID byte + data bytes + out[2] = sidOrRsid + copy(out[3:], data) + return out, nil +} + +// ParseMasterRequestFrame extracts a MasterRequestFrame from the wire-level +// lin.Frame received on LINDiagRequestID. It returns ErrInvalidFrame if f is +// not an 8-byte frame with a valid single-frame PCI. +// +//fusa:req REQ-SEC-005 +func ParseMasterRequestFrame(f Frame) (MasterRequestFrame, error) { + nad, sid, data, err := unpackDiag(f) + if err != nil { + return MasterRequestFrame{}, fmt.Errorf("lin: MasterRequestFrame: %w", err) + } + return MasterRequestFrame{NAD: nad, SID: sid, Data: data}, nil +} + +// ParseSlaveResponseFrame extracts a SlaveResponseFrame from the wire-level +// lin.Frame received on LINDiagResponseID. It returns ErrInvalidFrame if f +// is not an 8-byte frame with a valid single-frame PCI. +// +//fusa:req REQ-SEC-005 +func ParseSlaveResponseFrame(f Frame) (SlaveResponseFrame, error) { + nad, rsid, data, err := unpackDiag(f) + if err != nil { + return SlaveResponseFrame{}, fmt.Errorf("lin: SlaveResponseFrame: %w", err) + } + return SlaveResponseFrame{NAD: nad, RSID: rsid, Data: data}, nil +} + +// unpackDiag is the shared decode path for both diagnostic frame types: it +// validates length and PCI, then splits [NAD, PCI, SID/RSID, data...] out of +// f.Data per LIN 2.x §4.2.3. +func unpackDiag(f Frame) (nad, sidOrRsid uint8, data []byte, err error) { + if len(f.Data) != LINMaxDataLen { + return 0, 0, nil, fmt.Errorf("diagnostic frame must be %d bytes, got %d: %w", LINMaxDataLen, len(f.Data), ErrInvalidFrame) + } + nad = f.Data[0] + pci := f.Data[1] + if pci == 0 || pci > uint8(DiagDataLen+1) { + return 0, 0, nil, fmt.Errorf("diagnostic frame PCI 0x%02X out of single-frame range 0x01-0x%02X: %w", pci, DiagDataLen+1, ErrInvalidFrame) + } + sidOrRsid = f.Data[2] + n := int(pci) - 1 // data length = PCI (SID/RSID + data bytes) minus the SID/RSID byte + data = append([]byte(nil), f.Data[3:3+n]...) + return nad, sidOrRsid, data, nil +} diff --git a/diagnostic_test.go b/diagnostic_test.go new file mode 100644 index 0000000..7f1dfff --- /dev/null +++ b/diagnostic_test.go @@ -0,0 +1,126 @@ +// 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/. + +package lin_test + +import ( + "errors" + "testing" + + lin "github.com/SoundMatt/go-LIN" +) + +func TestMasterRequestFrame_toFromRoundTrip(t *testing.T) { + req := lin.MasterRequestFrame{NAD: 0x01, SID: 0xB2, Data: []byte{0x01, 0x02, 0x03}} + f, err := req.ToFrame() + if err != nil { + t.Fatalf("ToFrame: %v", err) + } + if f.ID != lin.LINDiagRequestID { + t.Errorf("f.ID = 0x%02X, want LINDiagRequestID (0x%02X)", f.ID, lin.LINDiagRequestID) + } + if len(f.Data) != lin.LINMaxDataLen { + t.Fatalf("f.Data length = %d, want %d", len(f.Data), lin.LINMaxDataLen) + } + if f.ChecksumType != lin.ClassicChecksum { + t.Errorf("f.ChecksumType = %v, want ClassicChecksum (diagnostic frames are always classic)", f.ChecksumType) + } + if err := lin.ValidateFrame(f); err != nil { + t.Errorf("ValidateFrame(ToFrame output) = %v, want nil", err) + } + + got, err := lin.ParseMasterRequestFrame(f) + if err != nil { + t.Fatalf("ParseMasterRequestFrame: %v", err) + } + if got.NAD != req.NAD || got.SID != req.SID { + t.Errorf("round-trip NAD/SID = 0x%02X/0x%02X, want 0x%02X/0x%02X", got.NAD, got.SID, req.NAD, req.SID) + } + if string(got.Data) != string(req.Data) { + t.Errorf("round-trip Data = % X, want % X", got.Data, req.Data) + } +} + +func TestSlaveResponseFrame_toFromRoundTrip(t *testing.T) { + resp := lin.SlaveResponseFrame{NAD: 0x01, RSID: 0xF2, Data: []byte{0xAA, 0xBB}} + f, err := resp.ToFrame() + if err != nil { + t.Fatalf("ToFrame: %v", err) + } + if f.ID != lin.LINDiagResponseID { + t.Errorf("f.ID = 0x%02X, want LINDiagResponseID (0x%02X)", f.ID, lin.LINDiagResponseID) + } + + got, err := lin.ParseSlaveResponseFrame(f) + if err != nil { + t.Fatalf("ParseSlaveResponseFrame: %v", err) + } + if got.NAD != resp.NAD || got.RSID != resp.RSID { + t.Errorf("round-trip NAD/RSID = 0x%02X/0x%02X, want 0x%02X/0x%02X", got.NAD, got.RSID, resp.NAD, resp.RSID) + } + if string(got.Data) != string(resp.Data) { + t.Errorf("round-trip Data = % X, want % X", got.Data, resp.Data) + } +} + +func TestMasterRequestFrame_emptyData(t *testing.T) { + req := lin.MasterRequestFrame{NAD: 0x7F, SID: 0x10} + f, err := req.ToFrame() + if err != nil { + t.Fatalf("ToFrame: %v", err) + } + got, err := lin.ParseMasterRequestFrame(f) + if err != nil { + t.Fatalf("ParseMasterRequestFrame: %v", err) + } + if len(got.Data) != 0 { + t.Errorf("Data = % X, want empty", got.Data) + } +} + +func TestMasterRequestFrame_dataTooLong(t *testing.T) { + req := lin.MasterRequestFrame{NAD: 0x01, SID: 0xB2, Data: make([]byte, lin.DiagDataLen+1)} + _, err := req.ToFrame() + if !errors.Is(err, lin.ErrInvalidFrame) { + t.Errorf("ToFrame(oversize data): errors.Is(err, lin.ErrInvalidFrame) = false, want true (err=%v)", err) + } +} + +func TestParseMasterRequestFrame_wrongLength(t *testing.T) { + f := lin.Frame{ID: lin.LINDiagRequestID, Data: []byte{0x01, 0x02}} + _, err := lin.ParseMasterRequestFrame(f) + if !errors.Is(err, lin.ErrInvalidFrame) { + t.Errorf("ParseMasterRequestFrame(short frame): errors.Is(err, lin.ErrInvalidFrame) = false, want true (err=%v)", err) + } +} + +func TestParseMasterRequestFrame_invalidPCI(t *testing.T) { + data := make([]byte, lin.LINMaxDataLen) + for i := range data { + data[i] = 0xFF + } + data[1] = 0x00 // PCI = 0 is out of the single-frame range + f := lin.Frame{ID: lin.LINDiagRequestID, Data: data} + _, err := lin.ParseMasterRequestFrame(f) + if !errors.Is(err, lin.ErrInvalidFrame) { + t.Errorf("ParseMasterRequestFrame(PCI=0): errors.Is(err, lin.ErrInvalidFrame) = false, want true (err=%v)", err) + } +} + +// TestDiagPadding verifies unused data bytes are padded with 0xFF, per +// LIN 2.x §4.2.3, not left as zero. +func TestDiagPadding(t *testing.T) { + req := lin.MasterRequestFrame{NAD: 0x01, SID: 0x10, Data: []byte{0x01}} + f, err := req.ToFrame() + if err != nil { + t.Fatalf("ToFrame: %v", err) + } + // bytes 0..3 are NAD, PCI, SID, data[0]; bytes 4..7 must be padding. + for i := 4; i < lin.LINMaxDataLen; i++ { + if f.Data[i] != 0xFF { + t.Errorf("f.Data[%d] = 0x%02X, want 0xFF padding", i, f.Data[i]) + } + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index b41ff47..428154e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,13 +1,29 @@ -# Multi-stage build for go-LIN quickstart and lintool binaries. +# Multi-stage build for go-LIN images: the RELAY-conformant go-lin CLI +# (published as ghcr.io/soundmatt/go-lin per spec §13.5), plus the quickstart +# and lintool example/legacy binaries. FROM golang:1.26-alpine AS builder WORKDIR /src COPY go.mod ./ RUN go mod download COPY . . +RUN CGO_ENABLED=0 go build -trimpath -o /bin/go-lin ./cmd/go-lin RUN CGO_ENABLED=0 go build -trimpath -o /bin/quickstart ./examples/quickstart RUN CGO_ENABLED=0 go build -trimpath -o /bin/lintool ./cmd/lintool +# ── go-lin image (RELAY-conformant CLI, spec §13.5) ─────────────────────────── +FROM alpine:3.21 AS go-lin +RUN apk add --no-cache ca-certificates +COPY --from=builder /bin/go-lin /usr/local/bin/go-lin +LABEL org.opencontainers.image.title="go-lin" \ + org.opencontainers.image.source="https://github.com/SoundMatt/go-LIN" \ + org.opencontainers.image.licenses="MPL-2.0" \ + io.relay.tool="go-lin" \ + io.relay.language="go" \ + io.relay.binary="go-lin" \ + io.relay.spec-version="1.11" +ENTRYPOINT ["go-lin"] + # ── quickstart image ────────────────────────────────────────────────────────── FROM alpine:3.21 AS quickstart RUN apk add --no-cache ca-certificates @@ -17,7 +33,7 @@ LABEL org.opencontainers.image.title="go-LIN quickstart" \ org.opencontainers.image.licenses="MPL-2.0" ENTRYPOINT ["quickstart"] -# ── lintool image ───────────────────────────────────────────────────────────── +# ── lintool image (legacy, non-RELAY-conformant example CLI) ───────────────── FROM alpine:3.21 AS lintool RUN apk add --no-cache ca-certificates COPY --from=builder /bin/lintool /usr/local/bin/lintool diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..2decc61 --- /dev/null +++ b/example_test.go @@ -0,0 +1,69 @@ +// 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/. + +package lin_test + +import ( + "fmt" + + lin "github.com/SoundMatt/go-LIN" +) + +// ExampleProtectID computes the Protected Identifier for a 6-bit LIN frame +// ID by appending the two LIN parity bits. +func ExampleProtectID() { + pid := lin.ProtectID(0x10) + fmt.Printf("0x%02X\n", pid) + // Output: 0x50 +} + +// ExampleVerifyPID recovers the raw frame ID from a Protected Identifier and +// rejects one with a corrupted parity bit. +func ExampleVerifyPID() { + pid := lin.ProtectID(0x10) + + id, err := lin.VerifyPID(pid) + fmt.Printf("id=0x%02X err=%v\n", id, err) + + _, err = lin.VerifyPID(pid ^ 0x80) // flip the top parity bit + fmt.Println(err != nil) + // Output: + // id=0x10 err= + // true +} + +// ExampleCalcChecksum computes both LIN checksum variants for the same +// payload: classic (LIN 1.x, data only) and enhanced (LIN 2.x, PID + data). +func ExampleCalcChecksum() { + pid := lin.ProtectID(0x10) + data := []byte{0x01, 0x02, 0x03, 0x04} + + classic := lin.CalcChecksum(0, data, lin.ClassicChecksum) + enhanced := lin.CalcChecksum(pid, data, lin.EnhancedChecksum) + fmt.Printf("classic=0x%02X enhanced=0x%02X\n", classic, enhanced) + // Output: classic=0xF5 enhanced=0xA5 +} + +// ExampleValidateFrame accepts a well-formed frame and rejects a +// structurally invalid one — here, data exceeding the 8-byte maximum. +func ExampleValidateFrame() { + ok := lin.Frame{ID: 0x10, Data: []byte{0x01, 0x02, 0x03, 0x04}} + fmt.Println(lin.ValidateFrame(ok) == nil) + + bad := lin.Frame{ID: 0x10, Data: make([]byte, lin.LINMaxDataLen+1)} + fmt.Println(lin.ValidateFrame(bad) != nil) + // Output: + // true + // true +} + +// ExampleFrame_ToMessage converts a Frame to the RELAY relay.Message +// envelope used by cross-protocol tooling (spec §15.3/§15.7.3). +func ExampleFrame_ToMessage() { + f := lin.Frame{ID: 0x10, Data: []byte{0x01, 0x02, 0x03, 0x04}, ChecksumType: lin.EnhancedChecksum} + msg := f.ToMessage() + fmt.Println(msg.Protocol, msg.ID, msg.Meta["lin.checksum_type"]) + // Output: LIN 16 enhanced +} diff --git a/examples/quickstart/main.go b/examples/quickstart/main.go index 83722f9..79d0f0f 100644 --- a/examples/quickstart/main.go +++ b/examples/quickstart/main.go @@ -28,8 +28,8 @@ import ( ) const ( - windowID = 0x10 // window position frame - mirrorID = 0x11 // mirror angle frame + windowID = 0x10 // window position frame + mirrorID = 0x11 // mirror angle frame intervalMs = 500 ) diff --git a/go.mod b/go.mod index 05814d7..6fff62f 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 v1.10.0 +require github.com/SoundMatt/RELAY v1.11.0 diff --git a/go.sum b/go.sum index 0516fd5..bb5c11d 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,5 @@ github.com/SoundMatt/RELAY v1.0.0 h1:eb4PvxHrJd0jsR97zBHOeTNJZXB69S3MWFpaknJho6c github.com/SoundMatt/RELAY v1.0.0/go.mod h1:8HXkxErdk4pHaM1de9eopm2S8o1aE7tKYkCBBsHxxcg= github.com/SoundMatt/RELAY v1.10.0 h1:P+UrbaMCwb44KKPdotBSPfjPtqnC6GkrCZg8lNSgtKE= github.com/SoundMatt/RELAY v1.10.0/go.mod h1:8HXkxErdk4pHaM1de9eopm2S8o1aE7tKYkCBBsHxxcg= +github.com/SoundMatt/RELAY v1.11.0 h1:ZqGQpU/ak5FNW4AOHjtVA6vPGjJhXCwFzAICHlHjNFE= +github.com/SoundMatt/RELAY v1.11.0/go.mod h1:8HXkxErdk4pHaM1de9eopm2S8o1aE7tKYkCBBsHxxcg= diff --git a/ldf/encode_test.go b/ldf/encode_test.go new file mode 100644 index 0000000..b2a75f0 --- /dev/null +++ b/ldf/encode_test.go @@ -0,0 +1,93 @@ +// 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/. + +package ldf_test + +import ( + "strings" + "testing" + + "github.com/SoundMatt/go-LIN/ldf" +) + +// TestEncode_packsSignals packs two 8-bit signals into a two-byte payload, +// exercising Encode and packBits across a byte boundary. +func TestEncode_packsSignals(t *testing.T) { + db, err := ldf.Parse(strings.NewReader(decodeLDF)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + out := db.Encode(0x10, map[string]uint64{"Lo8": 0xAB, "Hi8": 0xCD}) + if out == nil { + t.Fatal("Encode returned nil for a known frame") + } + want := []byte{0xAB, 0xCD} + if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] { + t.Errorf("Encode = % X, want % X", out, want) + } +} + +// TestEncode_roundTripsWithDecode verifies Decode(Encode(id, signals)) is the +// identity for signal values that fit within their declared bit width. +func TestEncode_roundTripsWithDecode(t *testing.T) { + db, err := ldf.Parse(strings.NewReader(decodeLDF)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + in := map[string]uint64{"Lo8": 0x42, "Hi8": 0x99} + payload := db.Encode(0x10, in) + out := db.Decode(0x10, payload) + for name, want := range in { + if out[name] != want { + t.Errorf("round-trip %s = 0x%02X, want 0x%02X", name, out[name], want) + } + } +} + +// TestEncode_missingSignalUsesInitValue verifies a signal absent from the +// input map is packed with its declared InitValue rather than left undefined. +func TestEncode_missingSignalUsesInitValue(t *testing.T) { + db, err := ldf.Parse(strings.NewReader(decodeLDF)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + // Neither signal is supplied; both InitValues in decodeLDF are 0x00. + out := db.Encode(0x10, nil) + if out[0] != 0x00 || out[1] != 0x00 { + t.Errorf("Encode(nil) = % X, want the signals' InitValue (00 00)", out) + } +} + +// TestEncode_unknownFrame returns nil for a frame ID that does not exist in +// the LDF, matching Decode's behaviour for the same case. +func TestEncode_unknownFrame(t *testing.T) { + db, err := ldf.Parse(strings.NewReader(decodeLDF)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if out := db.Encode(0x3F, map[string]uint64{"Lo8": 1}); out != nil { + t.Errorf("Encode(unknown frame) = % X, want nil", out) + } +} + +// TestEncode_truncatesOverwideValues verifies a value wider than the +// signal's declared BitWidth is truncated to the low BitWidth bits rather +// than corrupting adjacent signals. +func TestEncode_truncatesOverwideValues(t *testing.T) { + db, err := ldf.Parse(strings.NewReader(decodeLDF)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + out := db.Encode(0x10, map[string]uint64{"Lo8": 0x1FF, "Hi8": 0x00}) // Lo8 is only 8 bits wide + if out[0] != 0xFF { + t.Errorf("Lo8 truncated = 0x%02X, want 0xFF (low 8 bits of 0x1FF)", out[0]) + } + if out[1] != 0x00 { + t.Errorf("Hi8 = 0x%02X, want 0x00 (must not be corrupted by Lo8's overflow)", out[1]) + } +} diff --git a/ldf/example_test.go b/ldf/example_test.go new file mode 100644 index 0000000..e0ccbd9 --- /dev/null +++ b/ldf/example_test.go @@ -0,0 +1,52 @@ +// 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/. + +package ldf_test + +import ( + "fmt" + "strings" + + "github.com/SoundMatt/go-LIN/ldf" +) + +const exampleLDF = ` +LIN_description_file; +LIN_protocol_version = "2.1"; +LIN_speed = 19.2 kbps; + +Nodes { + Master: ECU, 1 ms, 0.1 ms; + Slaves: DoorModule; +} + +Signals { + WindowPos: 8, 0x00, DoorModule, ECU; +} + +Frames { + WindowStatus: 0x10, DoorModule, 1 { + WindowPos, 0; + } +} +` + +// Example parses an LDF, encodes a signal into a frame payload, then decodes +// it back — the round trip a write-then-read integration typically performs. +func Example() { + db, err := ldf.Parse(strings.NewReader(exampleLDF)) + if err != nil { + panic(err) + } + + payload := db.Encode(0x10, map[string]uint64{"WindowPos": 0x7F}) + fmt.Printf("payload=% X\n", payload) + + decoded := db.Decode(0x10, payload) + fmt.Printf("WindowPos=0x%02X\n", decoded["WindowPos"]) + // Output: + // payload=7F + // WindowPos=0x7F +} diff --git a/ldf/parser.go b/ldf/parser.go index 5d56564..bc2f86e 100644 --- a/ldf/parser.go +++ b/ldf/parser.go @@ -223,6 +223,57 @@ func extractBits(data []byte, bitOffset, bitWidth int) uint64 { return val } +// Encode packs signal values into a raw LIN frame payload (the write +// direction of Decode). +// +// The returned payload is sized to the frame's declared Length. Signals +// present in the LDF's frame definition but absent from the signals map are +// packed with the signal's declared InitValue, matching the LDF's own +// default-value semantics. Values wider than the signal's declared BitWidth +// are silently truncated to the low BitWidth bits (matching extractBits' +// inverse). Returns nil when id is not present in the LDF. +// +//fusa:req REQ-LDF-009 +//fusa:req REQ-LDF-010 +func (db *DB) Encode(id uint8, signals map[string]uint64) []byte { + f, ok := db.frames[id] + if !ok { + return nil + } + data := make([]byte, f.Length) + for _, ref := range f.Signals { + sig, ok := db.signals[ref.Name] + if !ok { + continue + } + val, provided := signals[ref.Name] + if !provided { + val = sig.InitValue + } + packBits(data, ref.BitOffset, sig.BitWidth, val) + } + return data +} + +// packBits writes the low bitWidth bits of val into data starting at +// bitOffset (LSB first, Intel byte order) — the exact inverse of extractBits. +// +//fusa:req REQ-LDF-009 +func packBits(data []byte, bitOffset, bitWidth int, val uint64) { + for i := 0; i < bitWidth; i++ { + byteIdx := (bitOffset + i) / 8 + bitIdx := uint((bitOffset + i) % 8) + if byteIdx >= len(data) { + break + } + if val&(1< LINMaxID { - return Frame{}, fmt.Errorf("lin: invalid frame ID %q", m.ID) + return Frame{}, fmt.Errorf("lin: invalid frame ID %q: %w", m.ID, ErrInvalidFrame) } ct := ClassicChecksum if m.Meta["lin.checksum_type"] == "enhanced" { diff --git a/lin_test.go b/lin_test.go index f9a3658..dde2a3a 100644 --- a/lin_test.go +++ b/lin_test.go @@ -285,6 +285,18 @@ func TestFromMessage_invalidID(t *testing.T) { } } +func TestFromMessage_invalidID_wrapsErrInvalidFrame(t *testing.T) { + cases := []string{"not-a-number", "64", "255"} + for _, id := range cases { + relay := lin.Frame{}.ToMessage() + relay.ID = id + _, err := lin.FromMessage(relay) + if !errors.Is(err, lin.ErrInvalidFrame) { + t.Errorf("FromMessage(ID=%q): errors.Is(err, lin.ErrInvalidFrame) = false, want true (err=%v)", id, err) + } + } +} + // ── SpecVersion ─────────────────────────────────────────────────────────────── func TestSpecVersion(t *testing.T) { diff --git a/master/example_test.go b/master/example_test.go new file mode 100644 index 0000000..8eed6eb --- /dev/null +++ b/master/example_test.go @@ -0,0 +1,66 @@ +// 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/. + +package master_test + +import ( + "context" + "fmt" + + lin "github.com/SoundMatt/go-LIN" + "github.com/SoundMatt/go-LIN/master" + "github.com/SoundMatt/go-LIN/virtual" +) + +// Example demonstrates a master node triggering a single frame exchange +// with SendHeader — the same call Run makes for each schedule slot, shown +// here directly for a deterministic, single-shot example. +func Example() { + bus, err := virtual.New() + if err != nil { + panic(err) + } + defer bus.Close() + + if err := bus.Publish(0x10, []byte{0x42, 0x00, 0x00, 0x00}); err != nil { + panic(err) + } + + n := master.New(bus) + f, err := n.SendHeader(context.Background(), 0x10) + if err != nil { + panic(err) + } + fmt.Printf("%02X#%X\n", f.ID, f.Data) + // Output: 10#42000000 +} + +// ExampleNode_Diagnostics drives one LIN diagnostic request/response +// exchange (LIN 2.x §4.2.3) against a pre-registered slave response. +func ExampleNode_Diagnostics() { + bus, err := virtual.New() + if err != nil { + panic(err) + } + defer bus.Close() + + // The diagnostic target's response, as a real slave would register it. + resp, err := lin.SlaveResponseFrame{NAD: 0x01, RSID: 0xF2, Data: []byte{0xAA}}.ToFrame() + if err != nil { + panic(err) + } + if err := bus.Publish(lin.LINDiagResponseID, resp.Data); err != nil { + panic(err) + } + + n := master.New(bus) + req := lin.MasterRequestFrame{NAD: 0x01, SID: 0xB2, Data: []byte{0x01}} + got, err := n.Diagnostics(context.Background(), req) + if err != nil { + panic(err) + } + fmt.Printf("NAD=0x%02X RSID=0x%02X Data=%X\n", got.NAD, got.RSID, got.Data) + // Output: NAD=0x01 RSID=0xF2 Data=AA +} diff --git a/master/master.go b/master/master.go index f38e112..8e824c5 100644 --- a/master/master.go +++ b/master/master.go @@ -33,12 +33,15 @@ //fusa:req REQ-MASTER-011 //fusa:req REQ-MASTER-012 //fusa:req REQ-MASTER-013 +//fusa:req REQ-MASTER-015 +//fusa:req REQ-MASTER-016 package master import ( "context" "errors" "fmt" + "sync" "time" lin "github.com/SoundMatt/go-LIN" @@ -52,6 +55,10 @@ type Node struct { schedule []lin.ScheduleEntry onFrame func(lin.Frame) onError func(error) + + mu sync.Mutex + pending map[uint8]bool + sporadic map[uint8][]uint8 } // New creates a LIN master node backed by bus. @@ -65,6 +72,11 @@ func New(bus lin.MasterBus) *Node { // before storing a defensive copy. It is safe to call between Run // invocations but must not be called concurrently with Run. // +// An empty slice is valid and disables scheduled transmission, matching the +// lin.MasterBus.SetSchedule contract (§8.3). Calling Run with an empty +// schedule still fails fast (REQ-MASTER-009); SetSchedule itself does not +// duplicate that check. +// //fusa:req REQ-MASTER-010 //fusa:req REQ-MASTER-011 //fusa:req REQ-MASTER-012 @@ -102,10 +114,114 @@ func (n *Node) SendHeader(ctx context.Context, id uint8) (lin.Frame, error) { return n.bus.SendHeader(ctx, id) } +// Diagnostics drives one LIN diagnostic request/response exchange (LIN 2.x +// §4.2.3): it publishes req's wire encoding on lin.LINDiagRequestID (0x3C) +// and triggers its header, then triggers lin.LINDiagResponseID (0x3D) and +// parses the target's response. +// +// If publishing or sending the request fails, Diagnostics returns without +// sending the response header — a diagnostic client must not solicit a +// response the target was never asked for. +// +//fusa:req REQ-MASTER-014 +func (n *Node) Diagnostics(ctx context.Context, req lin.MasterRequestFrame) (lin.SlaveResponseFrame, error) { + f, err := req.ToFrame() + if err != nil { + return lin.SlaveResponseFrame{}, fmt.Errorf("master: diagnostics: %w", err) + } + if err := n.bus.Publish(f.ID, f.Data); err != nil { + return lin.SlaveResponseFrame{}, fmt.Errorf("master: diagnostics: publish request: %w", err) + } + if _, err := n.bus.SendHeader(ctx, lin.LINDiagRequestID); err != nil { + return lin.SlaveResponseFrame{}, fmt.Errorf("master: diagnostics: send request header: %w", err) + } + resp, err := n.bus.SendHeader(ctx, lin.LINDiagResponseID) + if err != nil { + return lin.SlaveResponseFrame{}, fmt.Errorf("master: diagnostics: send response header: %w", err) + } + out, err := lin.ParseSlaveResponseFrame(resp) + if err != nil { + return lin.SlaveResponseFrame{}, fmt.Errorf("master: diagnostics: parse response: %w", err) + } + return out, nil +} + +// SetSporadicGroup declares slotID — a placeholder frame ID used as a +// lin.ScheduleEntry.ID — as a sporadic frame slot per LIN 2.x §2.3.2.4: +// candidates lists the real frame IDs sharing that slot, in priority order +// (index 0 = highest priority). When Run reaches a schedule slot whose ID +// equals slotID, it transmits the header of the highest-priority candidate +// that has a pending update (SetPending) and clears that candidate's +// pending flag; if no candidate is pending, the slot is skipped — no header +// is transmitted and neither OnFrame nor OnError is invoked for that pass. +// +// Passing a nil or empty candidates slice removes slotID's sporadic-group +// registration, so schedule slots using that ID resolve as an ordinary +// (non-sporadic) slot again. +// +//fusa:req REQ-MASTER-016 +func (n *Node) SetSporadicGroup(slotID uint8, candidates []uint8) error { + if slotID > lin.MaxID { + return fmt.Errorf("master: sporadic slot ID 0x%02X exceeds maximum 0x%02X", slotID, lin.MaxID) + } + for i, id := range candidates { + if id > lin.MaxID { + return fmt.Errorf("master: sporadic candidate %d: ID 0x%02X exceeds maximum 0x%02X", i, id, lin.MaxID) + } + } + + n.mu.Lock() + defer n.mu.Unlock() + if len(candidates) == 0 { + delete(n.sporadic, slotID) + return nil + } + if n.sporadic == nil { + n.sporadic = make(map[uint8][]uint8) + } + n.sporadic[slotID] = append([]uint8(nil), candidates...) + return nil +} + +// SetPending marks id's data as changed by the application, making it +// eligible for selection the next time Run reaches a sporadic slot +// (SetSporadicGroup) whose candidates include id. It has no effect on +// ordinary (non-sporadic) schedule slots. +// +//fusa:req REQ-MASTER-015 +func (n *Node) SetPending(id uint8) { + n.mu.Lock() + defer n.mu.Unlock() + if n.pending == nil { + n.pending = make(map[uint8]bool) + } + n.pending[id] = true +} + +// selectSporadic reports whether slotID is a registered sporadic group and, +// if so, returns the highest-priority pending candidate — clearing its +// pending flag — or ok=false if the group has no pending candidate. +func (n *Node) selectSporadic(slotID uint8) (id uint8, isSporadic, ok bool) { + n.mu.Lock() + defer n.mu.Unlock() + candidates, isSporadic := n.sporadic[slotID] + if !isSporadic { + return 0, false, false + } + for _, c := range candidates { + if n.pending[c] { + delete(n.pending, c) + return c, true, true + } + } + return 0, true, false +} + // Run executes the schedule table repeatedly until ctx is cancelled. // Each slot transmits a header, waits for a slave response, then sleeps // for the slot's configured delay. Per-slot errors invoke OnError but do -// not abort the schedule. +// not abort the schedule. A slot registered via SetSporadicGroup with no +// pending candidate is skipped (REQ-MASTER-016). // //fusa:req REQ-MASTER-003 //fusa:req REQ-MASTER-004 @@ -115,6 +231,7 @@ func (n *Node) SendHeader(ctx context.Context, id uint8) (lin.Frame, error) { //fusa:req REQ-MASTER-008 //fusa:req REQ-MASTER-009 //fusa:req REQ-MASTER-013 +//fusa:req REQ-MASTER-016 func (n *Node) Run(ctx context.Context) error { if len(n.schedule) == 0 { return errors.New("master: schedule is empty") @@ -127,16 +244,25 @@ func (n *Node) Run(ctx context.Context) error { default: } - f, err := n.bus.SendHeader(ctx, slot.ID) - if err != nil { - if n.onError != nil { - n.onError(fmt.Errorf("master: slot 0x%02X: %w", slot.ID, err)) - } - } else { - if n.onFrame != nil { - n.onFrame(f) + id, isSporadic, pending := n.selectSporadic(slot.ID) + if !isSporadic { + id = slot.ID + } + if !isSporadic || pending { + f, err := n.bus.SendHeader(ctx, id) + if err != nil { + if n.onError != nil { + n.onError(fmt.Errorf("master: slot 0x%02X: %w", id, err)) + } + } else { + if n.onFrame != nil { + n.onFrame(f) + } } } + // A sporadic slot with no pending candidate is skipped: no + // header is transmitted and neither callback fires, per + // LIN 2.x §2.3.2.4. if slot.DelayMs > 0 { select { @@ -150,13 +276,12 @@ func (n *Node) Run(ctx context.Context) error { } // validateSchedule checks that every schedule entry has a valid frame ID. +// An empty slice is valid: it matches lin.MasterBus.SetSchedule's documented +// contract that an empty schedule disables scheduled transmission. // //fusa:req REQ-MASTER-010 //fusa:req REQ-MASTER-011 func validateSchedule(entries []lin.ScheduleEntry) error { - if len(entries) == 0 { - return errors.New("master: schedule must have at least one entry") - } for i, e := range entries { if e.ID > lin.MaxID { return fmt.Errorf("master: schedule entry %d: ID 0x%02X exceeds maximum 0x%02X", i, e.ID, lin.MaxID) diff --git a/master/master_test.go b/master/master_test.go index d5b1191..8b25870 100644 --- a/master/master_test.go +++ b/master/master_test.go @@ -207,7 +207,10 @@ func TestRun_emptySchedule(t *testing.T) { } } -// ── REQ-MASTER-010: SetSchedule rejects empty ──────────────────────────────── +// ── REQ-MASTER-010: SetSchedule accepts an empty schedule ──────────────────── +// An empty slice is valid and disables scheduled transmission, matching the +// lin.MasterBus.SetSchedule contract. Running an empty schedule is still +// independently rejected by Run (REQ-MASTER-009). //fusa:test REQ-MASTER-010 @@ -216,11 +219,15 @@ func TestSetSchedule_empty(t *testing.T) { defer bus.Close() n := master.New(bus) - if err := n.SetSchedule(nil); err == nil { - t.Error("expected error for empty schedule") + if err := n.SetSchedule(nil); err != nil { + t.Errorf("SetSchedule(nil): %v, want nil (empty schedule disables scheduled transmission)", err) } - if err := n.SetSchedule([]lin.ScheduleEntry{}); err == nil { - t.Error("expected error for zero-length schedule") + if err := n.SetSchedule([]lin.ScheduleEntry{}); err != nil { + t.Errorf("SetSchedule([]): %v, want nil (empty schedule disables scheduled transmission)", err) + } + // Run still rejects the empty schedule it was just given (REQ-MASTER-009). + if err := n.Run(context.Background()); err == nil { + t.Error("Run with an empty schedule: expected error, got nil") } } @@ -324,3 +331,191 @@ func TestRun_continuesAfterError(t *testing.T) { cancel() <-done } + +// ── REQ-MASTER-014: Diagnostics drives a request/response exchange ─────────── + +//fusa:test REQ-MASTER-014 + +func TestDiagnostics_requestResponseRoundTrip(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + + // The diagnostic target's response, pre-registered on 0x3D as any slave + // would via slave.Node.SetResponse. + resp := lin.SlaveResponseFrame{NAD: 0x01, RSID: 0xF2, Data: []byte{0xAA}} + respFrame, err := resp.ToFrame() + if err != nil { + t.Fatalf("resp.ToFrame: %v", err) + } + if err := bus.Publish(lin.LINDiagResponseID, respFrame.Data); err != nil { + t.Fatalf("Publish(0x3D): %v", err) + } + + ch, _ := bus.Subscribe([]lin.Filter{{ID: lin.LINDiagRequestID}}) + + n := master.New(bus) + req := lin.MasterRequestFrame{NAD: 0x01, SID: 0xB2, Data: []byte{0x01, 0x02}} + got, err := n.Diagnostics(context.Background(), req) + if err != nil { + t.Fatalf("Diagnostics: %v", err) + } + if got.NAD != resp.NAD || got.RSID != resp.RSID || string(got.Data) != string(resp.Data) { + t.Errorf("Diagnostics response = %+v, want %+v", got, resp) + } + + // The request itself must actually have been transmitted on 0x3C. + select { + case sent := <-ch: + reqFrame, _ := req.ToFrame() + if string(sent.Data) != string(reqFrame.Data) { + t.Errorf("transmitted request data = % X, want % X", sent.Data, reqFrame.Data) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for the request frame to be broadcast") + } +} + +func TestDiagnostics_noResponseRegistered(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + + n := master.New(bus) + req := lin.MasterRequestFrame{NAD: 0x01, SID: 0xB2} + if _, err := n.Diagnostics(context.Background(), req); err == nil { + t.Error("Diagnostics with no registered 0x3D response: expected error, got nil") + } +} + +// ── REQ-MASTER-015/016: sporadic frame slots ────────────────────────────────── + +//fusa:test REQ-MASTER-015 +//fusa:test REQ-MASTER-016 + +// sporadicSlotID is an arbitrary schedule-table placeholder ID: any valid +// frame ID works, since SetSporadicGroup treats it purely as a key. +const sporadicSlotID = 0x30 + +func TestSporadic_selectsHighestPriorityPending(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + _ = bus.Publish(0x11, []byte{0x01}) + _ = bus.Publish(0x12, []byte{0x02}) + + n := master.New(bus) + if err := n.SetSporadicGroup(sporadicSlotID, []uint8{0x11, 0x12}); err != nil { + t.Fatalf("SetSporadicGroup: %v", err) + } + n.SetPending(0x12) + n.SetPending(0x11) // both pending; 0x11 has higher priority (listed first) + + ch, _ := bus.Subscribe([]lin.Filter{{All: true}}) + _ = n.SetSchedule([]lin.ScheduleEntry{{ID: sporadicSlotID, DelayMs: 0}}) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- n.Run(ctx) }() + + select { + case f := <-ch: + if f.ID != 0x11 { + t.Errorf("transmitted frame ID = 0x%02X, want 0x11 (higher priority)", f.ID) + } + case <-time.After(2 * time.Second): + cancel() + t.Fatal("timed out waiting for the sporadic frame") + } + cancel() + <-done +} + +func TestSporadic_skipsSlotWhenNothingPending(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + _ = bus.Publish(0x11, []byte{0x01}) + + n := master.New(bus) + if err := n.SetSporadicGroup(sporadicSlotID, []uint8{0x11}); err != nil { + t.Fatalf("SetSporadicGroup: %v", err) + } + // no SetPending call: the slot must be skipped, not error. + + var errs int + n.OnError(func(error) { errs++ }) + ch, _ := bus.Subscribe([]lin.Filter{{All: true}}) + _ = n.SetSchedule([]lin.ScheduleEntry{{ID: sporadicSlotID, DelayMs: 0}}) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = n.Run(ctx) + + select { + case f := <-ch: + t.Errorf("expected no frame transmitted for an empty sporadic slot, got 0x%02X", f.ID) + default: + } + if errs != 0 { + t.Errorf("OnError called %d time(s) for a correctly-skipped sporadic slot, want 0", errs) + } +} + +func TestSporadic_pendingClearedAfterSelection(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + _ = bus.Publish(0x11, []byte{0x01}) + + n := master.New(bus) + _ = n.SetSporadicGroup(sporadicSlotID, []uint8{0x11}) + n.SetPending(0x11) + _ = n.SetSchedule([]lin.ScheduleEntry{{ID: sporadicSlotID, DelayMs: 0}}) + + ch, _ := bus.Subscribe([]lin.Filter{{All: true}}) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _ = n.Run(ctx) + + // The first pass must transmit exactly one frame (pending consumed), + // not one per schedule iteration. + count := 0 +drain: + for { + select { + case <-ch: + count++ + default: + break drain + } + } + if count != 1 { + t.Errorf("frames transmitted = %d, want exactly 1 (pending flag must clear after selection)", count) + } +} + +func TestSetSporadicGroup_invalidID(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + n := master.New(bus) + if err := n.SetSporadicGroup(0x40, []uint8{0x11}); err == nil { + t.Error("SetSporadicGroup(slotID > MaxID): expected error, got nil") + } + if err := n.SetSporadicGroup(sporadicSlotID, []uint8{0x40}); err == nil { + t.Error("SetSporadicGroup(candidate > MaxID): expected error, got nil") + } +} + +func TestSetSporadicGroup_emptyRemovesRegistration(t *testing.T) { + bus, _ := virtual.New() + defer bus.Close() + n := master.New(bus) + _ = n.SetSporadicGroup(sporadicSlotID, []uint8{0x11}) + if err := n.SetSporadicGroup(sporadicSlotID, nil); err != nil { + t.Fatalf("SetSporadicGroup(nil): %v", err) + } + + // sporadicSlotID must now behave as an ordinary slot: SendHeader is + // attempted directly on it and fails with ErrNoResponse since nothing + // registered a response for that literal ID. + _ = bus.Publish(sporadicSlotID, nil) // ensure no stray response is registered + if _, err := n.SendHeader(context.Background(), sporadicSlotID); !errors.Is(err, lin.ErrNoResponse) { + t.Errorf("SendHeader after clearing sporadic group: err = %v, want ErrNoResponse", err) + } +} diff --git a/safety/e2e_test.go b/safety/e2e_test.go index 0b4d817..b62b05d 100644 --- a/safety/e2e_test.go +++ b/safety/e2e_test.go @@ -186,8 +186,8 @@ func TestUnwrap_sequenceGap(t *testing.T) { // skip seq 1 by creating a new protector (simulates gap) p2 := safety.NewProtector(cfg) - _ = p2.Protect([]byte{0x00}) // seq 0 - _ = p2.Protect([]byte{0x00}) // seq 1 — skip + _ = p2.Protect([]byte{0x00}) // seq 0 + _ = p2.Protect([]byte{0x00}) // seq 1 — skip third := p2.Protect([]byte{0x03}) // seq 2 _, err := r.Unwrap(third) diff --git a/seooc_test.go b/seooc_test.go index 02f1a5e..6651157 100644 --- a/seooc_test.go +++ b/seooc_test.go @@ -180,9 +180,9 @@ func TestSEOOC_OperatesAbovePhysicalLayer(t *testing.T) { func TestSEOOC_ValidateFrameOnExternalData(t *testing.T) { // Frames as they might arrive from untrusted hardware decode. bad := []lin.Frame{ - {ID: 0x40, Data: []byte{0x01}}, // ID overflow - {ID: 0x10, Data: nil}, // empty payload - {ID: 0x10, Data: make([]byte, 9)}, // oversized payload + {ID: 0x40, Data: []byte{0x01}}, // ID overflow + {ID: 0x10, Data: nil}, // empty payload + {ID: 0x10, Data: make([]byte, 9)}, // oversized payload } for i, f := range bad { if err := lin.ValidateFrame(f); err == nil { diff --git a/stats/example_test.go b/stats/example_test.go new file mode 100644 index 0000000..fc06213 --- /dev/null +++ b/stats/example_test.go @@ -0,0 +1,30 @@ +// 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/. + +package stats_test + +import ( + "fmt" + + lin "github.com/SoundMatt/go-LIN" + "github.com/SoundMatt/go-LIN/stats" +) + +// Example collects per-ID frame counts from a handful of observed frames. +// (FrameRate and BusLoad are omitted here since they depend on wall-clock +// time and are not deterministic across test runs.) +func Example() { + c := stats.New(19.2) // 19.2 kbit/s bus speed + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01}}) + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x02}}) + c.Observe(lin.Frame{ID: 0x20, Data: []byte{0x03}}) + + fmt.Println(c.TotalCount()) + perID := c.PerID() + fmt.Println(perID[0x10], perID[0x20]) + // Output: + // 3 + // 2 1 +} diff --git a/stats/stats.go b/stats/stats.go new file mode 100644 index 0000000..849797b --- /dev/null +++ b/stats/stats.go @@ -0,0 +1,166 @@ +// 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/. + +// Package stats provides observability counters for a LIN bus: frames +// received per second, per-ID frame counts, and an estimated bus load +// percentage. It is a passive observer — feed it frames via Observe (e.g. +// from a lin.Bus Subscribe channel) — and has no effect on bus behaviour. +// +// Usage: +// +// c := stats.New(19200) // bus speed in bps +// ch, _ := bus.Subscribe(nil) +// for f := range ch { +// c.Observe(f) +// } +// fmt.Println(c.FrameRate(), c.BusLoad(), c.PerID()) +package stats + +import ( + "sync" + "time" + + lin "github.com/SoundMatt/go-LIN" +) + +// perFrameOverheadBits is the fixed LIN 2.x per-frame wire overhead used by +// BusLoad's estimate: break field (>=13 bit-times) + break delimiter (1) + +// sync byte (10, including start/stop bits) + PID byte (10). Each data byte +// and the checksum byte add a further 10 bit-times each (8 data bits + 1 +// start + 1 stop), accounted for separately in bitsOnWire. +const perFrameOverheadBits = 13 + 1 + 10 + 10 + +// bitsPerByteOnWire is the UART framing cost of one byte on the LIN wire +// (1 start bit + 8 data bits + 1 stop bit). +const bitsPerByteOnWire = 10 + +// Collector accumulates frame statistics. The zero value is not usable; +// call New. A Collector is safe for concurrent use. +type Collector struct { + mu sync.Mutex + baudBPS float64 + start time.Time + totalCount uint64 + totalBits uint64 + perID map[uint8]uint64 +} + +// New creates a Collector. baudKbps is the LIN bus speed in kbit/s, used only +// by BusLoad; pass 0 if unknown (BusLoad then always returns 0). +// +//fusa:req REQ-STATS-001 +func New(baudKbps float64) *Collector { + return &Collector{ + baudBPS: baudKbps * 1000, + start: time.Now(), + perID: make(map[uint8]uint64), + } +} + +// Observe records one received frame. It is cheap enough to call from a hot +// receive loop. +// +//fusa:req REQ-STATS-002 +func (c *Collector) Observe(f lin.Frame) { + c.mu.Lock() + defer c.mu.Unlock() + c.totalCount++ + c.perID[f.ID]++ + c.totalBits += bitsOnWire(len(f.Data)) +} + +// bitsOnWire estimates the number of bit-times a LIN frame with the given +// data length occupies on the wire: fixed break+sync+PID overhead, plus one +// UART byte-time per data byte, plus one for the checksum byte. This is an +// estimate (LIN 2.x allows some flexibility in break length and inter-byte +// spacing); it is intended for observability, not timing-accurate +// simulation. +func bitsOnWire(dataLen int) uint64 { + return uint64(perFrameOverheadBits + (dataLen+1)*bitsPerByteOnWire) +} + +// FrameRate returns the mean number of frames observed per second since New +// or the last Reset. +// +//fusa:req REQ-STATS-003 +func (c *Collector) FrameRate() float64 { + c.mu.Lock() + defer c.mu.Unlock() + elapsed := time.Since(c.start).Seconds() + if elapsed <= 0 { + return 0 + } + return float64(c.totalCount) / elapsed +} + +// BusLoad returns the estimated percentage (0-100+) of theoretical bus +// bandwidth consumed by observed frames since New or the last Reset. It +// returns 0 if the Collector was created with baudKbps <= 0 or no time has +// elapsed. +// +//fusa:req REQ-STATS-004 +func (c *Collector) BusLoad() float64 { + c.mu.Lock() + defer c.mu.Unlock() + if c.baudBPS <= 0 { + return 0 + } + elapsed := time.Since(c.start).Seconds() + if elapsed <= 0 { + return 0 + } + usedBPS := float64(c.totalBits) / elapsed + return usedBPS / c.baudBPS * 100 +} + +// PerID returns a defensive copy of the per-frame-ID observation counters. +// +//fusa:req REQ-STATS-005 +func (c *Collector) PerID() map[uint8]uint64 { + c.mu.Lock() + defer c.mu.Unlock() + out := make(map[uint8]uint64, len(c.perID)) + for k, v := range c.perID { + out[k] = v + } + return out +} + +// TotalCount returns the total number of frames observed since New or the +// last Reset. +// +//fusa:req REQ-STATS-006 +func (c *Collector) TotalCount() uint64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.totalCount +} + +// Reset clears all counters and restarts the rate-measurement window. +// +//fusa:req REQ-STATS-007 +func (c *Collector) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.start = time.Now() + c.totalCount = 0 + c.totalBits = 0 + c.perID = make(map[uint8]uint64) +} + +// Watch subscribes ch's frames into c until ch closes. It is a convenience +// wrapper around Observe for callers that just want to point a Collector at +// a lin.Bus subscription; it runs until ch closes and does not return until +// then, so callers typically invoke it in its own goroutine: +// +// ch, _ := bus.Subscribe(nil) +// go c.Watch(ch) +// +//fusa:req REQ-STATS-008 +func (c *Collector) Watch(ch <-chan lin.Frame) { + for f := range ch { + c.Observe(f) + } +} diff --git a/stats/stats_test.go b/stats/stats_test.go new file mode 100644 index 0000000..7b57864 --- /dev/null +++ b/stats/stats_test.go @@ -0,0 +1,150 @@ +// 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/. + +package stats_test + +import ( + "sync" + "testing" + "time" + + lin "github.com/SoundMatt/go-LIN" + "github.com/SoundMatt/go-LIN/stats" +) + +// ── REQ-STATS-001: New returns a ready Collector ────────────────────────────── +// ── REQ-STATS-002: Observe updates total and per-ID counters ───────────────── + +//fusa:test REQ-STATS-001 +//fusa:test REQ-STATS-002 + +func TestObserve_totalCountAndPerID(t *testing.T) { + c := stats.New(19.2) + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01}}) + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x02}}) + c.Observe(lin.Frame{ID: 0x20, Data: []byte{0x03}}) + + if got := c.TotalCount(); got != 3 { + t.Errorf("TotalCount = %d, want 3", got) + } + perID := c.PerID() + if perID[0x10] != 2 { + t.Errorf("PerID[0x10] = %d, want 2", perID[0x10]) + } + if perID[0x20] != 1 { + t.Errorf("PerID[0x20] = %d, want 1", perID[0x20]) + } +} + +// ── REQ-STATS-005: PerID returns a defensive copy ───────────────────────────── + +//fusa:test REQ-STATS-005 + +func TestPerID_defensiveCopy(t *testing.T) { + c := stats.New(19.2) + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01}}) + perID := c.PerID() + perID[0x10] = 999 + perID[0x99] = 1 + if got := c.PerID()[0x10]; got != 1 { + t.Errorf("mutating the returned map affected the Collector: PerID[0x10] = %d, want 1", got) + } +} + +func TestFrameRate_positive(t *testing.T) { + c := stats.New(19.2) + for i := 0; i < 10; i++ { + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01}}) + } + time.Sleep(5 * time.Millisecond) + if rate := c.FrameRate(); rate <= 0 { + t.Errorf("FrameRate = %v, want > 0", rate) + } +} + +// ── REQ-STATS-004: BusLoad returns 0 when the bus speed is unknown ─────────── + +//fusa:test REQ-STATS-004 + +// TestBusLoad_zeroWhenNoBaud verifies BusLoad returns 0 when the Collector +// was created without a known bus speed (baudKbps <= 0). +func TestBusLoad_zeroWhenNoBaud(t *testing.T) { + c := stats.New(0) + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01, 0x02, 0x03, 0x04}}) + time.Sleep(time.Millisecond) + if load := c.BusLoad(); load != 0 { + t.Errorf("BusLoad (no baud) = %v, want 0", load) + } +} + +// TestBusLoad_increasesWithTraffic verifies BusLoad reports a higher load +// after observing more frames in the same window. +func TestBusLoad_increasesWithTraffic(t *testing.T) { + c := stats.New(19.2) + time.Sleep(2 * time.Millisecond) + before := c.BusLoad() + for i := 0; i < 50; i++ { + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}}) + } + after := c.BusLoad() + if after <= before { + t.Errorf("BusLoad after traffic = %v, want > BusLoad before = %v", after, before) + } +} + +// ── REQ-STATS-006: TotalCount reports the cumulative Observe count ─────────── +// ── REQ-STATS-007: Reset clears all counters and the rate window ───────────── + +//fusa:test REQ-STATS-006 +//fusa:test REQ-STATS-007 + +func TestReset_clearsCounters(t *testing.T) { + c := stats.New(19.2) + c.Observe(lin.Frame{ID: 0x10, Data: []byte{0x01}}) + c.Reset() + if got := c.TotalCount(); got != 0 { + t.Errorf("TotalCount after Reset = %d, want 0", got) + } + if got := len(c.PerID()); got != 0 { + t.Errorf("len(PerID()) after Reset = %d, want 0", got) + } +} + +// ── REQ-STATS-008: Watch observes every frame until the channel closes ─────── + +//fusa:test REQ-STATS-008 + +func TestWatch_consumesUntilClose(t *testing.T) { + c := stats.New(19.2) + ch := make(chan lin.Frame, 4) + ch <- lin.Frame{ID: 0x10, Data: []byte{0x01}} + ch <- lin.Frame{ID: 0x11, Data: []byte{0x02}} + close(ch) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c.Watch(ch) + }() + wg.Wait() + + if got := c.TotalCount(); got != 2 { + t.Errorf("TotalCount after Watch = %d, want 2", got) + } +} + +// ── REQ-STATS-003: FrameRate reports a non-negative, finite rate ───────────── + +//fusa:test REQ-STATS-003 + +func TestFrameRate_zeroBeforeAnyElapsedTime(t *testing.T) { + // A fresh Collector with no observations should report a non-negative, + // finite rate rather than dividing by zero. + c := stats.New(19.2) + if rate := c.FrameRate(); rate < 0 { + t.Errorf("FrameRate (fresh Collector) = %v, want >= 0", rate) + } +} diff --git a/virtual/bus.go b/virtual/bus.go index 38c1458..9dcc1d0 100644 --- a/virtual/bus.go +++ b/virtual/bus.go @@ -245,6 +245,10 @@ func (b *Bus) Close() error { // CloseWithDrain closes the bus after waiting for all subscriber channels to be // drained or until ctx expires. Implements lin.Drainer (RELAY §9). +// +// If draining completes before ctx expires, it returns nil. If ctx expires +// first, it closes immediately, counts every still-buffered frame across all +// subscriber channels into DropCount, and returns lin.ErrTimeout. func (b *Bus) CloseWithDrain(ctx context.Context) error { // Poll until all subscriber channels are empty or ctx is done. ticker := time.NewTicker(time.Millisecond) @@ -264,7 +268,17 @@ func (b *Bus) CloseWithDrain(ctx context.Context) error { } select { case <-ctx.Done(): - return b.Close() + b.mu.RLock() + var undelivered uint64 + for _, s := range b.subs { + undelivered += uint64(len(s.ch)) + } + b.mu.RUnlock() + b.dropCount.Add(undelivered) + if err := b.Close(); err != nil { + return err + } + return lin.ErrTimeout case <-ticker.C: } } diff --git a/virtual/coverage_test.go b/virtual/coverage_test.go index 3eac246..00cdd80 100644 --- a/virtual/coverage_test.go +++ b/virtual/coverage_test.go @@ -153,8 +153,11 @@ func TestCloseWithDrain_ctxCancel(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() - if err := bus.CloseWithDrain(ctx); err != nil { - t.Fatalf("CloseWithDrain (ctx cancel path): %v", err) + if err := bus.CloseWithDrain(ctx); !errors.Is(err, lin.ErrTimeout) { + t.Fatalf("CloseWithDrain (ctx cancel path) = %v, want errors.Is(err, lin.ErrTimeout)", err) + } + if m := bus.Metrics(); m.DropCount == 0 { + t.Errorf("Metrics.DropCount = 0 after CloseWithDrain timed out with an undelivered frame, want > 0") } } diff --git a/virtual/example_test.go b/virtual/example_test.go new file mode 100644 index 0000000..8581a7d --- /dev/null +++ b/virtual/example_test.go @@ -0,0 +1,41 @@ +// 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/. + +package virtual_test + +import ( + "context" + "fmt" + + lin "github.com/SoundMatt/go-LIN" + "github.com/SoundMatt/go-LIN/virtual" +) + +// Example demonstrates the smallest complete virtual-bus round trip: a +// slave response is registered with Publish, a subscriber listens for it, +// and SendHeader drives the exchange as a master would. +func Example() { + bus, err := virtual.New() + if err != nil { + panic(err) + } + defer bus.Close() + + ch, err := bus.Subscribe([]lin.Filter{{ID: 0x10}}) + if err != nil { + panic(err) + } + + if err := bus.Publish(0x10, []byte{0x42, 0x00, 0x00, 0x00}); err != nil { + panic(err) + } + if _, err := bus.SendHeader(context.Background(), 0x10); err != nil { + panic(err) + } + + f := <-ch + fmt.Printf("%02X#%X\n", f.ID, f.Data) + // Output: 10#42000000 +}