From Sensor Into On-Board Service via Signal-to-Service APIs

(Disclaimer: I'm not an automotive software engineer, nor have I worked as such. This is solely based on my limited knowledge of how software in cars works, as I try to learn and understand.)

As I've been exploring automotive software development, I wanted to understand the architecture and how data is handled across different layers. To get hands-on experience with these concepts, I built a simple fuel level monitoring system to understand and demonstrate several key on-board fundamentals of the Service-Oriented Architecture (SOA) framework for vehicles.

The SOA Framework I'm Learning About

Before diving into my project, I should explain what I'm trying to understand. Modern and future vehicles are developing to use a layered SOA approach that includes:

  1. Embedded Runtimes: The lowest level, handling direct sensor interactions
  2. Signal-to-Service APIs: Converting raw signals into meaningful services
  3. Container Runtime: Running microservices on the vehicle
  4. Vehicle-to-Cloud API: Connecting on-board and off-board environments
  5. Cloud Runtime: Handling off-board processing and applications

What is fascinating about this is how data flows through these layers, from low-level sensors to user-facing applications.
For more details, I recommend checking out the SDV Guide from digital.auto.

For this project, I'll simulate the flow of the first three levels with a simple fuel level monitoring system. (No real reason I chose that system, just to start somewhere)

My Project: A Fuel Level Monitoring System with gRPC

To better understand these concepts, I created a project with three main components that mirror some of the SOA layers:

  1. ECU Simulation (Embedded Runtime): A C++ module that simulates fuel level sensor readings
  2. gRPC Service (Signal-to-Service API): Converts the raw sensor data into standardised service calls
  3. Client Application (Container Runtime): A microservice written in Go that consumes the gRPC service and displays the data
A simple diagram showing how the three components map to the SOA layers to help visualize the architecture

Starting at the Embedded Level: The Fuel Level Sensor

My first task was to understand how sensor data originates at the ECU level. I created a C++ class that simulates a fuel level sensor:

FuelLevelSensor::FuelLevelSensor(float initial_level, float consumption_rate)
    : current_level_(std::max(0.0f, std::min(100.0f, initial_level))),
      consumption_rate_(consumption_rate),
      read_count_(0)
{
    // Initialize random seed for noise generation
    std::srand(static_cast<unsigned int>(std::time(nullptr)));
}

This corresponds to what the industry literature calls the "Embedded Runtime" layer, where basic sensor functions happen. The sensor simulation includes:

  • Simple state tracking (current fuel level)
  • Gradual consumption simulation
  • Noise generation
  • Range validation

The core reading method demonstrates how raw sensor data is generated:

float FuelLevelSensor::read_fuel_level()
{
    // Simulate fuel consumption over time
    current_level_ = std::max(0.0f, current_level_ - consumption_rate_);
    
    // Add some noise to the reading
    float noise = generate_noise();
    
    // Ensure reading stays within valid range (0-100%)
    float reading = std::max(0.0f, std::min(100.0f, current_level_ + noise));
    
    read_count_++;
    return reading;
}

While this is just a simulation, it helped me understand how ECUs might handle raw sensor values before they're exposed through higher-level abstractions.

Creating a Signal-to-Service API with gRPC

The next layer in the SOA framework is the Signal-to-Service API, which transforms raw signals into higher-level services. I believe in the real world, standards such as AUTOSAR, COVESA VSS and SOAFEE are used, and data travels through the Automotive Ethernet.

However, for this project, I implemented this layer using gRPC as a simplified alternative to these standards for learning purposes, defining a service that exposes the fuel level readings:

service OBDService {
  // Gets the current fuel level
  rpc GetFuelLevel(FuelLevelRequest) returns (FuelLevelResponse) {}
  
  // Stream fuel level updates periodically
  rpc StreamFuelLevel(FuelLevelStreamRequest) returns (stream FuelLevelResponse) {}
}

message FuelLevelResponse {
  // Fuel level in percent (0.0 - 100.0)
  float level_percent = 1;
  uint64 timestamp_ms = 2;
  int32 status = 3;
  string error_message = 4;
}

This service definition helped me understand how raw sensor values are transformed into structured API responses with additional metadata, such as timestamps and status codes.

The implementation connects the low-level sensor to the service API:

grpc::Status OBDServiceImpl::GetFuelLevel(grpc::ServerContext* context,
                                        const obd::FuelLevelRequest* request,
                                        obd::FuelLevelResponse* response) {
    std::lock_guard<std::mutex> lock(sensor_mutex_);
    
    try {
        float level = fuel_sensor_.read_fuel_level();
        *response = CreateFuelLevelResponse(level);
        return grpc::Status::OK;
    } catch (const std::exception& e) {
        response->set_status(1);
        response->set_error_message(e.what());
        return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
    }
}

One key insight I gained from this implementation was understanding where things are in each layer and how they interact.

Simulating an Application in Container Runtime

The final step was building a client application to simulate an on-board container runtime application. I created a Go client that could represent an on-board application running in the vehicle's container runtime, even though I'm not using containers at this stage:

func getSingleReading(client pb.OBDServiceClient, vehicleID string) {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    response, err := client.GetFuelLevel(ctx, &pb.FuelLevelRequest{VehicleId: vehicleID})
    if err != nil {
        log.Fatalf("Error getting fuel level: %v", err)
    }

    fmt.Printf("Fuel level: %.2f%%\n", response.LevelPercent)
    
    // Create JSON output for cloud storage or processing
    jsonData := map[string]interface{}{
        "level_percent": response.LevelPercent,
        "timestamp_ms":  response.TimestampMs,
        "status":        response.Status,
    }
    jsonBytes, _ := json.Marshal(jsonData)
    fmt.Printf("JSON: %s\n", string(jsonBytes))
}

The Go client helped me understand how on-board applications could work and communicate with the S2S API.

Streaming Updates: Simulating Real-Time Vehicle Data

I also wanted to understand how continuous data flows could work in vehicle systems. The streaming functionality in my project demonstrated this concept:

func streamReadings(client pb.OBDServiceClient, vehicleID string, intervalSeconds int) {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    request := &pb.FuelLevelStreamRequest{
        VehicleId:       vehicleID,
        IntervalSeconds: uint32(intervalSeconds),
    }

    stream, err := client.StreamFuelLevel(ctx, request)
    if err != nil {
        log.Fatalf("Error setting up stream: %v", err)
    }

    for count < 10 {
        response, err := stream.Recv()
        if err == io.EOF {
            break // End of stream
        }
        
        fmt.Printf("Fuel level update: %.2f%%\n", response.LevelPercent)
    }
}

This streaming implementation could represent how real-time telemetry data flows from the vehicle to cloud applications, enabling features like remote monitoring and analytics.

Insights

As a newcomer to automotive software, this project helped me understand a couple of key aspects of the SOA framework:

  1. Separation of Concerns: The layered approach (ECU → Service API → Applications) demonstrates how complex vehicle systems can be broken down into manageable components.
  2. The Value of Abstraction: The Signal-to-Service API concept became clearer—by abstracting raw sensor readings into structured services, we simplify application development.

Conclusion

Building this fuel level monitoring system has been a great learning experience for understanding the layers of a vehicle's Service-Oriented Architecture. While my implementation is simplified compared to production automotive software, it's helped me grasp the fundamental concepts of how data flows from embedded sensors through service APIs to on-board applications.

I'm excited to continue building upon this foundation in my next projects, moving deeper into the SOA framework and exploring more complex interactions between vehicle systems and the cloud.


You can see the full code for this implementation here under the p1 tag.