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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Test validate_dbc action

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- run: python -m pip install --upgrade "cantools>=40,<41" pytest
- run: python -m pytest test/ -v

# Smoke test that the composite action itself runs end to end. The validator
# logic (including that broken files fail) is covered by unit-tests above
action-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Action runs clean on a valid file
uses: ./
with:
files: test/fixtures/valid.dbc
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.pytest_cache/
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,77 @@
# validate_dbc
Github Action for validating .dbc files using cantools


A GitHub Action that lints CAN `.dbc` files for **syntax and structural
defects** using [cantools](https://github.com/cantools/cantools). Inspired by DBC Utility's [DBC validation release checklist](https://dbcutility.com/blog/dbc-validation-release-checklist/), it runs the checks that can be done without a live CAN bus. This should at the very least provide a lint.

Findings are emitted as GitHub Actions annotations, so they show up inline on
the pull request. Errors fail the job, and warnings are reported but do not result in failure.

## What it checks

| Check | Severity |
|---|---|
| File does not parse in cantools (syntax / format error) | **error** |
| Signal overlap, out-of-bounds signal, or multiplexer error | **error** |
| Classical CAN message with payload length above 8 bytes | **error** |
| Duplicate signal name within a single message | **error** |
| Duplicate frame ids across messages (may be variant-specific) | warning |
| Standard id above 11 bits not marked extended | warning |
| CAN FD message with a non-standard payload length | warning |
| Physical min/max that cannot fit the signal's bit width and scale | warning |

**Out of scope** (needs a real bus, captured logs, or human review, so it is
deliberately *not* checked): log replay, physical-value plausibility, unit
correctness, enum-vs-firmware agreement, and release-note review.

## Usage

```yaml
name: DBC Lint
on:
pull_request:
paths: ['**.dbc']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: polymathrobotics/validate_dbc@v1
# No inputs needed: validates every .dbc in the repo by default.
```

Validate only a single directory:

```yaml
- uses: polymathrobotics/validate_dbc@v1
with:
path: dbc_dir
```

Validate an explicit set of files:

```yaml
- uses: polymathrobotics/validate_dbc@v1
with:
files: |
path/to/example.dbc
```

## Inputs

| Input | Default | Description |
|---|---|---|
| `path` | `.` | Directory searched recursively for `.dbc` files when `files` is empty. |
| `files` | `''` | Explicit space/newline-separated list of `.dbc` files. Overrides `path`. |
| `python-version` | `3.10` | Python version used to run the validator. |
| `cantools-version` | `>=40,<41` | Version specifier appended to `cantools` for `pip install`. |

## Versioning

Pin to a major version tag (`@v1`) to receive compatible updates, or to a full
commit SHA for maximum reproducibility.

## License

Apache-2.0 — see [LICENSE](LICENSE).
57 changes: 57 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: 'Validate DBC'
description: 'Github Action for validating .dbc files using cantools.'
author: 'Polymath Robotics'

branding:
icon: 'check-circle'
color: 'green'

inputs:
path:
description: 'Directory to search recursively for .dbc files (used when `files` is empty).'
required: false
default: '.'
files:
description: 'Explicit space- or newline-separated list of .dbc files. Overrides `path` when set.'
required: false
default: ''
python-version:
description: 'Python version used to run the validator.'
required: false
default: '3.10'
cantools-version:
description: 'pip version specifier appended to "cantools" (e.g. ">=40,<41").'
required: false
default: '>=40,<41'

runs:
using: 'composite'
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}

- name: Install cantools
shell: bash
env:
CANTOOLS_VERSION: ${{ inputs.cantools-version }}
run: python -m pip install --upgrade "cantools${CANTOOLS_VERSION}"

- name: Validate DBC files
shell: bash
env:
INPUT_FILES: ${{ inputs.files }}
INPUT_PATH: ${{ inputs.path }}
ACTION_PATH: ${{ github.action_path }}
run: |
if [ -n "$INPUT_FILES" ]; then
read -r -a files <<< "$INPUT_FILES"
else
mapfile -t files < <(find "$INPUT_PATH" -type f -name '*.dbc' | sort)
fi
if [ "${#files[@]}" -eq 0 ]; then
echo "No .dbc files found."
exit 0
fi
python "$ACTION_PATH/scripts/validate_dbc.py" "${files[@]}"
167 changes: 167 additions & 0 deletions scripts/validate_dbc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Validate .dbc files for syntax and structural defects.

Runs the CI-automatable subset of a DBC lint checks inspired by DBC Utility's DBC Validation Release Checklist
(https://dbcutility.com/blog/dbc-validation-release-checklist/) against the
DBC files passed as command-line arguments.
"""

from __future__ import annotations

import re
import sys

import cantools

# Valid CAN FD data-length-code payload sizes, in bytes.
CAN_FD_VALID_LENGTHS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64}
CLASSICAL_CAN_MAX_LENGTH = 8
STANDARD_ID_MAX = 0x7FF

_LINE_RE = re.compile(r'at line (\d+)')


class Finding:
"""A single validation finding tied to a file (and optionally a line)."""

def __init__(self, path, level, message, line=None):
self.path = path
self.level = level # 'error' or 'warning'
self.message = message
self.line = line

def annotation(self):
"""Render as a GitHub Actions workflow command."""
location = f'file={self.path}'
if self.line is not None:
location += f',line={self.line}'
text = self.message.replace('\n', ' ').strip()
return f'::{self.level} {location}::{text}'


def _line_from_error(exc):
"""Pull a line number out of a cantools error message, if present."""
match = _LINE_RE.search(str(exc))
return int(match.group(1)) if match else None


def _check_parse(path):
"""Load with strict=False. Returns (db, findings); db is None on failure."""
try:
db = cantools.database.load_file(path, strict=False)
except Exception as exc: # noqa: BLE001 - any load failure is a syntax/format error
return None, [Finding(path, 'error', f'Failed to parse: {exc}', _line_from_error(exc))]
return db, []


def _check_strict(path):
"""Semantic checks (overlap / bounds / mux) via cantools strict mode.

cantools strict mode does the byte-order-aware bit-layout math itself, so we
reuse it instead of re-implementing Motorola/Intel bit numbering by hand.
"""
try:
cantools.database.load_file(path, strict=True)
except Exception as exc: # noqa: BLE001 - strict-only failures are structural defects
return [Finding(path, 'error', f'Structural error (overlap/bounds/mux): {exc}',
_line_from_error(exc))]
return []


def _check_signal_ranges(path, msg):
"""Warn when a declared physical range cannot fit the signal's bit width."""
findings = []
for sig in msg.signals:
if sig.minimum is None or sig.maximum is None or not sig.scale:
continue
raw_span = (2 ** sig.length) - 1
representable = abs(raw_span * sig.scale)
declared = abs(sig.maximum - sig.minimum)
# Allow one scale step of tolerance for rounding in the DBC.
if declared > representable + abs(sig.scale):
findings.append(Finding(
path, 'warning',
f'Signal "{msg.name}.{sig.name}" declared range [{sig.minimum}, {sig.maximum}] '
f'exceeds what {sig.length} bits at scale {sig.scale} can represent (~{representable})'))
return findings


def _check_messages(path, db):
"""Structural checks cantools does not perform on its own."""
findings = []
seen_ids = {}

for msg in db.messages:
seen_ids.setdefault((msg.frame_id, bool(msg.is_extended_frame)), []).append(msg.name)

if not msg.is_fd and msg.length > CLASSICAL_CAN_MAX_LENGTH:
findings.append(Finding(
path, 'error',
f'Message "{msg.name}" is classical CAN but has payload length '
f'{msg.length} > {CLASSICAL_CAN_MAX_LENGTH} bytes'))

if msg.is_fd and msg.length not in CAN_FD_VALID_LENGTHS:
findings.append(Finding(
path, 'warning',
f'Message "{msg.name}" is CAN FD with non-standard payload length {msg.length}'))

if not msg.is_extended_frame and msg.frame_id > STANDARD_ID_MAX:
findings.append(Finding(
path, 'warning',
f'Message "{msg.name}" id 0x{msg.frame_id:X} exceeds 11 bits but is not marked extended'))

counts = {}
for sig in msg.signals:
counts[sig.name] = counts.get(sig.name, 0) + 1
for name, count in counts.items():
if count > 1:
findings.append(Finding(
path, 'error',
f'Message "{msg.name}" has duplicate signal name "{name}"'))

findings.extend(_check_signal_ranges(path, msg))

for (frame_id, extended), names in seen_ids.items():
if len(names) > 1:
kind = 'extended' if extended else 'standard'
findings.append(Finding(
path, 'warning',
f'Duplicate {kind} frame id 0x{frame_id:X} used by messages: {", ".join(names)}'))

return findings


def validate_file(path):
"""Run every check against a single file and return its findings."""
db, findings = _check_parse(path)
if db is None:
return findings
signal_count = sum(len(m.signals) for m in db.messages)
print(f'{path}: parsed OK ({len(db.messages)} messages, {signal_count} signals)')
findings.extend(_check_strict(path))
findings.extend(_check_messages(path, db))
return findings


def main(argv):
paths = argv[1:]
if not paths:
print('No DBC files to validate.')
return 0

all_findings = []
for path in paths:
all_findings.extend(validate_file(path))

for finding in all_findings:
print(finding.annotation())

errors = [f for f in all_findings if f.level == 'error']
warnings = [f for f in all_findings if f.level == 'warning']
print(f'\nSummary: {len(errors)} error(s), {len(warnings)} warning(s) '
f'across {len(paths)} file(s).')
return 1 if errors else 0


if __name__ == '__main__':
sys.exit(main(sys.argv))
12 changes: 12 additions & 0 deletions test/fixtures/error_classical_oversize.dbc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
VERSION ""


NS_ :

BS_:

BU_:


BO_ 300 Oversize_Message: 16 Vector__XXX
SG_ Signal_Z : 0|8@1+ (1,0) [0|255] "" Vector__XXX
13 changes: 13 additions & 0 deletions test/fixtures/error_duplicate_signal_name.dbc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
VERSION ""


NS_ :

BS_:

BU_:


BO_ 400 Dup_Signal_Message: 8 Vector__XXX
SG_ Same_Name : 0|8@1+ (1,0) [0|255] "" Vector__XXX
SG_ Same_Name : 8|8@1+ (1,0) [0|255] "" Vector__XXX
13 changes: 13 additions & 0 deletions test/fixtures/error_overlap.dbc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
VERSION ""


NS_ :

BS_:

BU_:


BO_ 200 Overlap_Message: 8 Vector__XXX
SG_ Signal_X : 0|16@1+ (1,0) [0|65535] "" Vector__XXX
SG_ Signal_Y : 8|16@1+ (1,0) [0|65535] "" Vector__XXX
12 changes: 12 additions & 0 deletions test/fixtures/error_syntax.dbc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
VERSION ""


NS_ :

BS_:

BU_:


BO_ not_a_number Bad_Message: 8 Vector__XXX
SG_ Some_Signal : 0|8@1+ (1,0) [0|255] "" Vector__XXX
Loading
Loading