Skip to content

Commit 5a32498

Browse files
authored
Add overlapping signal bit tests. (#97)
This is to protect against accidental pollution of the signalsets due to merging commands from multiple vehicles together.
1 parent 1950761 commit 5a32498

File tree

5 files changed

+185
-0
lines changed

5 files changed

+185
-0
lines changed

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(python -m pytest:*)",
5+
"Bash(python3 -m pytest:*)",
6+
"Bash(/usr/bin/python3 -m pytest:*)"
7+
],
8+
"deny": [],
9+
"ask": []
10+
}
11+
}

python/json_formatter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from typing import Dict, List, Any, Optional
22
import json
33

4+
from signals_testing import check_overlapping_signals
5+
46
def format_commands(commands: List[Dict[str, Any]]) -> str:
57
"""Format a list of commands, sorting them by cmd parameter ID and removing duplicates.
68
@@ -699,13 +701,19 @@ def format_file(input_path: str, output_path: Optional[str] = None) -> str:
699701
700702
Returns:
701703
Formatted JSON string
704+
705+
Raises:
706+
OverlappingSignalError: If any signals within a command have overlapping bit definitions
702707
"""
703708
# Read and parse input JSON
704709
with open(input_path, 'r') as f:
705710
data = json.load(f)
706711

707712
formatted = format_json_data(data)
708713

714+
# Validate no overlapping signals after formatting (in case formatting merged commands)
715+
check_overlapping_signals(formatted)
716+
709717
# Write to output file if specified
710718
if output_path:
711719
with open(output_path, 'w') as f:

python/signals_testing.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,87 @@ def test_method(self):
460460
setattr(target_module, class_name, test_class)
461461

462462
return True
463+
464+
465+
class OverlappingSignalError(Exception):
466+
"""Raised when signals within a command have overlapping bit definitions."""
467+
pass
468+
469+
470+
def check_overlapping_signals(signalset_json: str) -> List[Dict[str, Any]]:
471+
"""
472+
Check a signalset for overlapping signal bit definitions within commands.
473+
474+
Uses a bitset approach: for each command, track which bits are occupied.
475+
If a signal tries to use a bit that's already occupied, it's an overlap.
476+
477+
Args:
478+
signalset_json: JSON string containing the signal set definition
479+
480+
Returns:
481+
List of overlap errors, each containing:
482+
- command: command identifier (hdr/cmd)
483+
- signal_id: the signal that caused the overlap
484+
- bit: the bit index that was already occupied
485+
- conflicting_signal_id: the signal that already occupied the bit
486+
487+
Raises:
488+
OverlappingSignalError: If any overlapping signals are found
489+
"""
490+
import json
491+
signalset = json.loads(signalset_json)
492+
493+
errors = []
494+
495+
for command in signalset.get('commands', []):
496+
# Track which bits are occupied and by which signal
497+
occupied_bits: Dict[int, str] = {}
498+
499+
cmd_identifier = f"hdr={command.get('hdr', '?')}, cmd={command.get('cmd', '?')}"
500+
501+
for signal in command.get('signals', []):
502+
signal_id = signal.get('id', 'unknown')
503+
fmt = signal.get('fmt', {})
504+
505+
start_bit = fmt.get('bix', 0)
506+
bit_length = fmt.get('len', 0)
507+
508+
for bit in range(start_bit, start_bit + bit_length):
509+
if bit in occupied_bits:
510+
errors.append({
511+
'command': cmd_identifier,
512+
'signal_id': signal_id,
513+
'bit': bit,
514+
'conflicting_signal_id': occupied_bits[bit]
515+
})
516+
# Only report the first conflicting bit per signal
517+
break
518+
occupied_bits[bit] = signal_id
519+
520+
if errors:
521+
error_messages = []
522+
for err in errors:
523+
error_messages.append(
524+
f"Signal '{err['signal_id']}' overlaps with '{err['conflicting_signal_id']}' "
525+
f"at bit {err['bit']} in command [{err['command']}]"
526+
)
527+
raise OverlappingSignalError(
528+
f"Found {len(errors)} overlapping signal(s):\n" + "\n".join(error_messages)
529+
)
530+
531+
return errors
532+
533+
534+
def test_no_overlapping_signals(signalset_json: str):
535+
"""
536+
Test that a signalset has no overlapping signal bit definitions.
537+
538+
This function can be used by other repos to validate their signalset files.
539+
540+
Args:
541+
signalset_json: JSON string containing the signal set definition
542+
543+
Raises:
544+
OverlappingSignalError: If any overlapping signals are found
545+
"""
546+
check_overlapping_signals(signalset_json)

python/test_overlappingsignals.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import os
2+
import pytest
3+
from pathlib import Path
4+
5+
from json_formatter import format_file
6+
from signals_testing import check_overlapping_signals, OverlappingSignalError
7+
8+
TESTDATA_DIR = os.path.join(Path(__file__).parent, 'testdata')
9+
10+
11+
def test_overlapping_signals_rejected_by_format_file():
12+
"""Test that format_file raises OverlappingSignalError for bad-overlappingsignals.json."""
13+
bad_file = os.path.join(TESTDATA_DIR, 'bad-overlappingsignals.json')
14+
15+
with pytest.raises(OverlappingSignalError) as exc_info:
16+
format_file(bad_file)
17+
18+
error_message = str(exc_info.value)
19+
assert "F150_ODO_2" in error_message
20+
assert "F150_ODO" in error_message
21+
22+
23+
def test_overlapping_signals_detected():
24+
"""Test that overlapping signals in bad-overlappingsignals.json are detected."""
25+
bad_file = os.path.join(TESTDATA_DIR, 'bad-overlappingsignals.json')
26+
with open(bad_file) as f:
27+
signalset_json = f.read()
28+
29+
with pytest.raises(OverlappingSignalError) as exc_info:
30+
check_overlapping_signals(signalset_json)
31+
32+
# Verify the error message contains the expected signal IDs
33+
error_message = str(exc_info.value)
34+
assert "F150_ODO_2" in error_message
35+
assert "F150_ODO" in error_message
36+
37+
38+
def test_non_overlapping_signals_pass():
39+
"""Test that non-overlapping signals pass validation."""
40+
signalset_json = '''{
41+
"commands": [{
42+
"hdr": "720", "rax": "728", "cmd": {"22": "404C"}, "freq": 5,
43+
"signals": [
44+
{"id": "SIG_A", "fmt": {"bix": 0, "len": 8}},
45+
{"id": "SIG_B", "fmt": {"bix": 8, "len": 8}},
46+
{"id": "SIG_C", "fmt": {"bix": 16, "len": 16}}
47+
]
48+
}]
49+
}'''
50+
# Should not raise
51+
check_overlapping_signals(signalset_json)
52+
53+
54+
def test_overlapping_signals_with_implicit_bix():
55+
"""Test that implicit bix=0 is handled correctly for overlap detection."""
56+
signalset_json = '''{
57+
"commands": [{
58+
"hdr": "720", "rax": "728", "cmd": {"22": "404C"}, "freq": 5,
59+
"signals": [
60+
{"id": "SIG_A", "fmt": {"len": 8}},
61+
{"id": "SIG_B", "fmt": {"len": 4}}
62+
]
63+
}]
64+
}'''
65+
with pytest.raises(OverlappingSignalError) as exc_info:
66+
check_overlapping_signals(signalset_json)
67+
68+
error_message = str(exc_info.value)
69+
assert "SIG_B" in error_message
70+
assert "SIG_A" in error_message
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{ "commands": [
2+
{ "hdr": "720", "rax": "728", "cmd": {"22": "404C"}, "freq": 5,
3+
"signals": [
4+
{"id": "F150_ODO", "path": "Trips", "fmt": { "len": 24, "max": 1677721, "div": 10, "unit": "kilometers" }, "name": "Odometer (Instrument panel)", "suggestedMetric": "odometer"},
5+
{"id": "F150_ODO_2", "path": "Trips", "fmt": { "len": 8, "max": 1677721, "div": 10, "unit": "kilometers" }, "name": "Odometer (Instrument panel)", "suggestedMetric": "odometer"}
6+
]},
7+
{ "hdr": "726", "rax": "72E", "cmd": {"22": "2813"}, "freq": 5,
8+
"signals": [
9+
{"id": "F150_TP_FL", "path": "Tires", "fmt": { "len": 16, "max": 3276, "div": 20, "unit": "psi" }, "name": "Front left tire pressure", "suggestedMetric": "frontLeftTirePressure"}
10+
]}
11+
]
12+
}

0 commit comments

Comments
 (0)