Vehicle to Cloud: Building a Real-Time Fuel Level Dashboard

(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 continue to explore automotive software development and build on top of what I built last time, From Sensor Into On-Board Service via Signal-to-Service APIs, I've added a Vehicle-to-Cloud API and a dashboard for displaying the data.
Here we'll walk through building a complete Vehicle-to-Cloud connection that displays real-time fuel level data and controls headlights.
Refreshing the SOA Framework
Before diving into the code, let's do a quick reminder of the SOA approach and where Vehicle-to-Cloud fits within it:
- Embedded Runtime: The lowest level, handling direct sensor interactions
- Signal-to-Service APIs: Transforms raw signals into meaningful services
- Container Runtime: Running micro-services on the vehicle
- Vehicle-to-Cloud API: Creates a bridge between vehicle systems and cloud platforms
- Cloud Runtime: Handling off-board processing and applications
For more details, I recommend checking out the SDV Guide from digital.auto.
For this project, I'll focus on the last two (to three) levels by building a system for monitoring and controlling vehicle data through a V2C (Vehicle-to-Cloud) gateway.
The Project: Building a Basic V2C (Vehicle-to-Cloud) Connection for Monitor and Control
For this project, I created a simple V2C system that allows a cloud environment to request fuel level data from a vehicle and set the state of the headlight. Here's the architecture I implemented:
- ECU Simulation (Embedded Runtime): A C++ module that simulates fuel level sensor readings (existing from phase 1 with added headlight state control)
- gRPC Service (Signal-to-Service API): Converts the raw data into standardised service calls (existing from phase 1)
- Client Application (Container Runtime): A microservice written in Go that consumes the gRPC service (existing from phase 1)
- HTTP Gateway (Vehicle-to-Cloud API): A new V2C Gateway in Go that exposes the vehicle's capabilities as HTTP endpoints
- Dashboard (Cloud Runtime): A simple Python web app that requests and displays the vehicle data

Small Addition to the ECU: New Headlight Control
I wanted to add the functionality to send commands and change the state of something in this simulated project of mine to understand how actions travel through the stack.
So, I added a simple, very simple implementation of the headlight state:
Lights::Lights() : is_headlight_on_(false) {}
bool Lights::set_headlight(bool state) {
is_headlight_on_ = state;
return true;
}
bool Lights::get_headlight_state() {
return is_headlight_on_;
}
In addition to that, I improved the code structure and added some minor enhancements to simplify developing and debugging:
zonal_controller/
├── include/ # Header files
│ ├── hardware/ # Hardware interface headers
│ ├── services/ # Service implementation headers
│ ├── config.hpp # Configuration management
│ ├── logger.hpp # Logging utilities
│ └── version.h.in # Version information template
├── src/ # Source files
│ ├── hardware/ # Hardware implementation
│ ├── services/ # Service implementations
│ ├── config.cpp # Configuration implementation
│ └── server_main.cpp # Main server entry point
├── build/ # Build directory
├── CMakeLists.txt # Build configuration
├── config.yaml # Configuration file
└── README.md # README file
Updating the Signal-to-Service API
Adding the new headlight control meant I needed to update the S2S API, which was fairly straightforward.
service LightingService {
// Get the current state of the headlight
rpc GetHeadlightState(GetHeadlightStateRequest) returns (GetHeadlightStateResponse) {}
// Set the state of the headlight
rpc SetHeadlight(SetHeadlightRequest) returns (SetHeadlightResponse) {}
}
message GetHeadlightStateResponse {
bool is_on = 1;
}
message SetHeadlightRequest {
bool turn_on = 1;
}
message SetHeadlightResponse {
bool success = 1;
}
The implementation of that looked like this:
grpc::Status LightingService::GetHeadlightState(
grpc::ServerContext *context,
const lighting::GetHeadlightStateRequest *request,
lighting::GetHeadlightStateResponse *response)
{
try
{
// Call the embedded function to get state
bool state = body_lights_.get_headlight_state();
// Convert to boolean and set response
response->set_is_on(state == 1);
return grpc::Status::OK;
}
catch (const std::exception &e)
{
// Handle any exceptions
return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
}
}
Easy, breezy, beautiful.
Adjustments To the On-Board Service
The final adjustment needed before adding new things was updating the client application, simulating an on-board container runtime application:
func (c *service) GetHeadlightState(ctx context.Context) (*HeadlightState, error) {
res, err := c.lightClient.GetHeadlightState(ctx, &pb.GetHeadlightStateRequest{})
if err != nil {
return nil, fmt.Errorf("error getting headlight state: %w", err)
}
return &HeadlightState{
HeadlightOn: res.IsOn,
}, nil
}
New V2C Gateway
I built the V2C Gateway as a Go HTTP server that exposes REST endpoints for cloud services to consume. The gateway's main responsibility is translating these HTTP requests into calls to our on-board service. For simplicity, the on-board service includes both the HTTP server and the gRPC client that communicates with the S2S API.
func main() {
// Initialize the gRPC client
obClient, err := signal.NewService(cfg.OBServerAddr, cfg.DefaultVehicle)
if err != nil {
log.Fatalf("Failed to create OB client: %v", err)
}
// Set up HTTP server
r := chi.NewRouter()
fuelHandler := handlers.NewFuelHandler(s.signalsClient, s.defaultVehicle)
lightHandler := handlers.NewLightHandler(s.signalsClient, s.defaultVehicle)
r.Route("/api/v1/vehicle", func(r chi.Router) {
r.Get("/data/fuel_level", fuelHandler.GetFuelLevel)
r.Get("/stream/fuel_level", fuelHandler.StreamFuelLevel)
r.Route("/lighting/headlights", func(r chi.Router) {
r.Get("/", lightHandler.GetHeadlights)
r.Put("/", lightHandler.SetHeadlights)
})
})
// Start HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: r,
}
go func() {
log.Printf("V2C Gateway starting on :%d...", s.port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("ListenAndServe(): %v", err)
}
}()
}
func (h *FuelHandler) GetFuelLevel(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
data, err := h.client.GetFuelLevel(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"status": "success",
"vehicle_id": h.vehicleID,
"data_key": "fuel_level",
"data": data,
"timestamp": time.Now().Unix(),
"request_id": r.Header.Get("X-Request-ID"),
}
json.NewEncoder(w).Encode(response)
}
This gateway implements four key V2C API endpoints:
GET /api/v1/vehicle/data/fuel_level
- For requesting the current fuel levelGET /api/v1/vehicle/stream/fuel_level
- For streaming fuel level updates over timeGET /api/v1/vehicle/lighting/headlights
- For requesting the current headlight statePUT /api/v1/vehicle/lighting/headlights
- For updating the headlight state
As with the ECU (zonal_controller), I also used this opportunity to improve the code structure and make some adjustments:
signalservice/
├── cmd/
│ └── signalservice/
│ └── main.go # Application entry point
├── internal/
│ ├── config/ # Configuration management
│ ├── gateway/ # gRPC gateway implementation
│ ├── handlers/ # Request handlers
│ └── signal/ # Signal processing logic
├── pkg/
│ └── middleware/ # Shared middleware components
├── proto/ # Protocol buffer definitions
└── go.mod # Go module definition
Cloud Simulation
To test the V2C Gateway, I created a simple cloud simulation using Python and Flask. This provides a basic dashboard UI and communicates with the V2C Gateway to request and display vehicle data.
@views_bp.route('/')
def index():
"""Render the dashboard UI"""
vehicle_data = data_store.get_all()
return render_template('dashboard.html', data=vehicle_data)
@api_bp.route('/request_data', methods=['POST'])
def request_data():
"""Request data from vehicle via V2C gateway"""
data_key = request.json.get('key')
if not data_key:
return jsonify({"error": "No data key provided"}), 400
# Currently only supporting fuel_level
if data_key != "fuel_level":
return jsonify({"error": f"Unsupported data key: {data_key}"}), 400
try:
result = VehicleDataService.get_instance().request_fuel_level()
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
Lessons Learned
As a newcomer to automotive software, this project helped me understand a couple of key aspects of the SOA framework:
- Careful of AI Code: I'm using AI code generators to help me with development. However, I encountered an issue where AI produced code that used a deprecated parameter, causing the streaming function to stop working. Eventually, I found the problem (with AI help), but you need to stay on top of what code is being generated.
- Separation of Concerns: Organising the code was really helpful when issues came up. It made it easier to debug and understand the flow.
Conclusion
Building this simulated Vehicle-to-Cloud connection has been helpful in getting a clearer picture and an overview of the stack. Even though it's very raw, it's been useful for understanding the whole flow from top to bottom.
You can see the complete code for this implementation here under the p2
tag.