diff --git a/.github/workflows/cereal_validation.yaml b/.github/workflows/cereal_validation.yaml new file mode 100644 index 000000000..5d75b6bc1 --- /dev/null +++ b/.github/workflows/cereal_validation.yaml @@ -0,0 +1,77 @@ +name: cereal validation + +on: + push: + branches: + - master + - master-new + pull_request: + paths: + - 'cereal/**' + workflow_dispatch: + workflow_call: + inputs: + run_number: + default: '1' + required: true + type: string + +concurrency: + group: cereal-validation-ci-run-${{ inputs.run_number }}-${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/master-new') && github.run_id || github.head_ref || github.ref }}-${{ github.workflow }}-${{ github.event_name }} + cancel-in-progress: true + +env: + PYTHONWARNINGS: error + BASE_IMAGE: openpilot-base + BUILD: selfdrive/test/docker_build.sh base + RUN: docker run --shm-size 2G -v $PWD:/tmp/openpilot -w /tmp/openpilot -e CI=1 -e PYTHONWARNINGS=error -e FILEREADER_CACHE=1 -e PYTHONPATH=/tmp/openpilot -e NUM_JOBS -e JOB_ID -e GITHUB_ACTION -e GITHUB_REF -e GITHUB_HEAD_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_RUN_ID -v $GITHUB_WORKSPACE/.ci_cache/scons_cache:/tmp/scons_cache -v $GITHUB_WORKSPACE/.ci_cache/comma_download_cache:/tmp/comma_download_cache -v $GITHUB_WORKSPACE/.ci_cache/openpilot_cache:/tmp/openpilot_cache $BASE_IMAGE /bin/bash -c + +jobs: + generate_cereal_artifact: + name: Generate cereal validation artifacts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: ./.github/workflows/setup-with-retry + - name: Build openpilot + run: ${{ env.RUN }} "scons -j$(nproc) cereal" + - name: Generate the log file + run: | + ${{ env.RUN }} "cereal/messaging/tests/validate_sp_cereal_upstream.py -g -f schema_instances.bin" && \ + ls -la + ls -la cereal/messaging/tests + - name: 'Prepare artifact' + run: | + mkdir -p "cereal/messaging/tests/cereal_validations" + cp cereal/messaging/tests/validate_sp_cereal_upstream.py "cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py" + cp schema_instances.bin "cereal/messaging/tests/cereal_validations/schema_instances.bin" + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: cereal_validations + path: cereal/messaging/tests/cereal_validations + + validate_cereal_with_upstream: + name: Validate cereal with Upstream + runs-on: ubuntu-24.04 + needs: generate_cereal_artifact + steps: + - uses: actions/checkout@v4 + with: + repository: 'commaai/openpilot' + submodules: true + ref: "refs/heads/master" + - uses: ./.github/workflows/setup-with-retry + - name: Build openpilot + run: ${{ env.RUN }} "scons -j$(nproc) cereal" + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: cereal_validations + path: cereal/messaging/tests/cereal_validations + - name: 'Run the validation' + run: | + chmod +x cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py + ${{ env.RUN }} "cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py -r -f cereal/messaging/tests/cereal_validations/schema_instances.bin" diff --git a/cereal/custom.capnp b/cereal/custom.capnp index 9c280349c..8d09392fc 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -102,19 +102,23 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { } struct OnroadEventSP @0xda96579883444c35 { - name @0 :EventName; + events @0 :List(Event); - # event types - enable @1 :Bool; - noEntry @2 :Bool; - warning @3 :Bool; # alerts presented only when enabled or soft disabling - userDisable @4 :Bool; - softDisable @5 :Bool; - immediateDisable @6 :Bool; - preEnable @7 :Bool; - permanent @8 :Bool; # alerts presented regardless of openpilot state - overrideLateral @10 :Bool; - overrideLongitudinal @9 :Bool; + struct Event { + name @0 :EventName; + + # event types + enable @1 :Bool; + noEntry @2 :Bool; + warning @3 :Bool; # alerts presented only when enabled or soft disabling + userDisable @4 :Bool; + softDisable @5 :Bool; + immediateDisable @6 :Bool; + preEnable @7 :Bool; + permanent @8 :Bool; # alerts presented regardless of openpilot state + overrideLateral @10 :Bool; + overrideLongitudinal @9 :Bool; + } enum EventName { lkasEnable @0; diff --git a/cereal/log.capnp b/cereal/log.capnp index 0d1fcf19a..8595bcda0 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2579,7 +2579,7 @@ struct Event { selfdriveStateSP @107 :Custom.SelfdriveStateSP; modelManagerSP @108 :Custom.ModelManagerSP; longitudinalPlanSP @109 :Custom.LongitudinalPlanSP; - onroadEventsSP @110 :List(Custom.OnroadEventSP); + onroadEventsSP @110 :Custom.OnroadEventSP; carParamsSP @111 :Custom.CarParamsSP; carControlSP @112 :Custom.CarControlSP; backupManagerSP @113 :Custom.BackupManagerSP; diff --git a/cereal/messaging/tests/validate_sp_cereal_upstream.py b/cereal/messaging/tests/validate_sp_cereal_upstream.py new file mode 100755 index 000000000..9ccd6533c --- /dev/null +++ b/cereal/messaging/tests/validate_sp_cereal_upstream.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +import argparse +import sys +from typing import Any, List, Tuple + +DEBUG = False + + +def print_debug(string: str) -> None: + if DEBUG: + print(string) + + +def create_schema_instance(struct: Any, prop: Tuple[str, Any]) -> Any: + """ + Create a new instance of a schema type, handling different field types. + + Args: + struct: The Cap'n Proto schema structure + prop: A tuple containing the field name and field metadata + + Returns: + A new initialized schema instance + """ + struct_instance = struct.new_message() + field_name, field_metadata = prop + + try: + field_type = field_metadata.proto.slot.type.which() + + # Initialize different types of fields + if field_type in ('list', 'text', 'data'): + struct_instance.init(field_name, 1) + print_debug(f"Initialized list/text/data field: {field_name}") + elif field_type in ('struct', 'object'): + struct_instance.init(field_name) + print_debug(f"Initialized struct/object field: {field_name}") + + return struct_instance + + except Exception as e: + print(f"Error creating instance for {field_name}: {e}") + return None + + +def get_schema_fields(schema_struct: Any) -> List[Tuple[str, Any]]: + """ + Retrieve all fields from a given schema structure. + + Args: + schema_struct: The Cap'n Proto schema structure + + Returns: + A list of field names and their metadata + """ + try: + # Get all fields from the schema + schema_fields = list(schema_struct.schema.fields.items()) + + print_debug("Discovered schema fields:") + for field_name, field_metadata in schema_fields: + print_debug(f"- {field_name}") + + return schema_fields + + except Exception as e: + print(f"Error retrieving schema fields: {e}") + return [] + + +def generate_schema_instances(schema_struct: Any) -> List[Any]: + """ + Generate instances for all fields in a given schema. + + Args: + schema_struct: The Cap'n Proto schema structure + + Returns: + A list of schema instances + """ + schema_fields = get_schema_fields(schema_struct) + instances = [] + + for field_prop in schema_fields: + try: + instance = create_schema_instance(schema_struct, field_prop) + if instance is not None: + instances.append(instance) + except Exception as e: + print(f"Skipping field due to error: {e}") + + print(f"Generated {len(instances)} schema instances") + return instances + + +def persist_instances(instances: List[Any], filename: str) -> None: + """ + Write schema instances to a binary file. + + Args: + instances: List of schema instances + filename: Output file path + """ + try: + with open(filename, 'wb') as f: + for instance in instances: + f.write(instance.to_bytes()) + + print(f"Successfully wrote {len(instances)} instances to {filename}") + + except Exception as e: + print(f"Error persisting instances: {e}") + sys.exit(1) + + +def read_instances(filename: str, schema_type: Any) -> List[Any]: + """ + Read schema instances from a binary file. + + Args: + filename: Input file path + schema_type: The schema type to use for reading + + Returns: + A list of read schema instances + """ + try: + with open(filename, 'rb') as f: + data = f.read() + + instances = list(schema_type.read_multiple_bytes(data)) + + print(f"Read {len(instances)} instances from {filename}") + return instances + + except Exception as e: + print(f"Error reading instances: {e}") + sys.exit(1) + + +def compare_schemas(original_instances: List[Any], read_instances: List[Any]) -> bool: + """ + Compare original and read-back instances to detect potential breaking changes. + + Args: + original_instances: List of originally generated instances + read_instances: List of instances read back from file + + Returns: + Boolean indicating whether schemas appear compatible + """ + if len(original_instances) != len(read_instances): + print("❌ Schema Compatibility Warning: Instance count mismatch") + return False + + compatible = True + for struct in read_instances: + try: + getattr(struct, struct.which()) # Attempting to access the field to validate readability + except Exception as e: + print(f"❌ Structural change detected: {struct.which()} is not readable.\nFull error: {e}") + compatible = False + + return compatible + + +def main(): + """ + CLI entry point for schema compatibility testing. + """ + # Setup argument parser + parser = argparse.ArgumentParser( + description='Cap\'n Proto Schema Compatibility Testing Tool', + epilog='Test schema compatibility by generating and reading back instances.' + ) + + # Add mutually exclusive group for generation or reading mode + mode_group = parser.add_mutually_exclusive_group(required=True) + mode_group.add_argument('-g', '--generate', action='store_true', + help='Generate schema instances') + mode_group.add_argument('-r', '--read', action='store_true', + help='Read and validate schema instances') + + # Common arguments + parser.add_argument('-f', '--file', + default='schema_instances.bin', + help='Output/input binary file (default: schema_instances.bin)') + + # Parse arguments + args = parser.parse_args() + + # Import the schema dynamically + try: + from cereal import log + schema_type = log.Event + except ImportError: + print("Error: Unable to import schema. Ensure 'cereal' is installed.") + sys.exit(1) + + # Execute based on mode + if args.generate: + print("🔧 Generating Schema Instances") + instances = generate_schema_instances(schema_type) + persist_instances(instances, args.file) + print("✅ Instance generation complete") + + elif args.read: + print("🔍 Reading and Validating Schema Instances") + generated_instances = generate_schema_instances(schema_type) + read_back_instances = read_instances(args.file, schema_type) + + # Compare schemas + if compare_schemas(generated_instances, read_back_instances): + print("✅ Schema Compatibility: No breaking changes detected") + sys.exit(0) + else: + print("❌ Potential Schema Breaking Changes Detected") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 9a28a3616..b6a3348d5 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -509,9 +509,9 @@ class SelfdriveD(CruiseHelper): # onroadEventsSP - logged every second or on change if (self.sm.frame % int(1. / DT_CTRL) == 0) or (self.events_sp.names != self.events_sp_prev): - ce_send_sp = messaging.new_message('onroadEventsSP', len(self.events_sp)) + ce_send_sp = messaging.new_message('onroadEventsSP') ce_send_sp.valid = True - ce_send_sp.onroadEventsSP = self.events_sp.to_msg() + ce_send_sp.onroadEventsSP.events = self.events_sp.to_msg() self.pm.send('onroadEventsSP', ce_send_sp) self.events_sp_prev = self.events_sp.names.copy() diff --git a/sunnypilot/selfdrive/selfdrived/events.py b/sunnypilot/selfdrive/selfdrived/events.py index ac564692a..7767a6db7 100644 --- a/sunnypilot/selfdrive/selfdrived/events.py +++ b/sunnypilot/selfdrive/selfdrived/events.py @@ -26,7 +26,7 @@ class EventsSP(EventsBase): return EVENT_NAME_SP[event] def get_event_msg_type(self): - return custom.OnroadEventSP + return custom.OnroadEventSP.Event EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = {