Article

Unleashing gRPC Part 2: Diving Deeper into Machine Control Using Flutter with gRPC

September 30, 2024

 

 

By Blake Lapum | Senior Software Engineer |

 

 

In part one [Click HERE] of the Unleashing gRPC blog series, we discussed an example embedded system use case where utilizing Flutter and gRPC could be applicable. In this post we will be taking the example even further by looking at example backend and frontend Flutter application code to complete the PoC IoT light system. 

The Backend

In part one, we left off with a complete unleashing_grpc library that is ready for consumption. We will start with the backend/, which we will be setting up to be a headless Flutter application (“headless” as in there is no GUI – this may seem weird, but I am not currently aware of a simple way to compile Dart CLI program(s) in our OS build system and since there is no GUI, there is little overhead on our target hardware). The backend application is the application responsible for serving the gRPC server and actually controlling the hardware. Even though we want a headless application, the backend should still be created as a Flutter project.

To start, we must import the packages our application is dependent on in the backend/pubspec.yaml. In this case, we know we need our new library (which just so happens to be two directories behind backend/) and we will also be depending on the dart_periphery package to interact with the General Purpose Input Output (GPIO) the LED is wired up to:

...


dependencies:
...
 unleashing_grpc:
   path: ../../
 dart_periphery: ^0.9.6
...

With the dependencies installed, it is time to complete the implementation of the backend/ service with the required implementation of our MachineControlServiceBase class. It is typically a good idea to create a mocked out service for cases where you want to run a dummy implementation of the service in a different environment for testing purposes, but for now we will focus on the real, target implementation:

class MachineControl {
 late final GPIO _gpio;
 late final EventBus _eventBus;


 MachineControl(final int gpioNum, final EventBus eventBus) {
   _eventBus = eventBus;
   _gpio = GPIO(gpioPin, GPIOdirection.gpioDirOut);
 }


 int get gpioNum => _gpio.line;


 void dispose() {
   _gpio.dispose();
 }


 void setLedState(final bool ledOn) {
   _gpio.write(ledOn);
   _eventBus.fire(LedStateEvent(ledOn));
 }


 bool get ledState => _gpio.read();
}


class MachineControlService extends MachineControlServiceBase {
 late final MachineControlBase _machineControl;
 late final EventBus _eventBus;


 MachineControlService(
   final MachineControlBase machineControl,
   final EventBus eventBus,
 ) {
   _machineControl = machineControl;
   _eventBus = eventBus;
 }


 @override
 Future<Empty> setLedState(
   final ServiceCall call,
   final LedState request,
 ) async {
   _machineControl.setLedState(request.ledOn);


   return Empty();
 }


 @override
 Stream<LedState> streamLedState(
   final ServiceCall call,
   final Empty request,
 ) async* {
   yield* _eventBus.on<LedStateEvent>().map((final LedStateEvent event) {
     return LedState(ledOn: event.ledOn);
   });
 }
}

While most of the file is fairly straightforward, one piece of the implementation to call out is the use of the event_bus package. This allows us to decouple the streamLedState logic and use an event-driven architecture by listening for LedStateEvents, which are published by MachineControl.setLedState whenever a state (LED on/off) change occurs. 

The gRPC service and machine control logic is implemented so the last step is instantiating each in our main() entrypoint and the background backend/ service is complete!

import 'package:event_bus/event_bus.dart';
import 'package:flutter/widgets.dart';


import 'package:unleashing_grpc/unleashing_grpc.dart';


import 'machine_control_service.dart';


void main() async {
 // Notice there is no `runApp` invocation - this means there will not be
 // any UI to interact with as this is a purely headless Flutter application.


 WidgetsFlutterBinding.ensureInitialized();


 const String hostname = "raspberrypi4.local";
 const int gpio = 6;


 final EventBus eventBus = EventBus();
 final MachineControlBase machineControl = MachineControl(gpio, eventBus);


 final MachineControlGrpcServer server = MachineControlGrpcServer(
   service: MachineControlService(machineControl, eventBus),
   hostname: hostname,
 );


 await server.start().onError((final Object error, final StackTrace stackTrace) {
   machineControl.dispose();
   exit(1);
 });


 // Handle SIGINT (Ctrl+C) to stop the gRPC server and clean up.
 ProcessSignal.sigint.watch().listen((final ProcessSignal signal) async {
   await server.stop();
   machineControl.dispose();
   exit(0);
 });
}

Tip: although the main entry point is nice and concise, do not forget to be a good boyscout and clean up any resources on the way out!

The Frontend

The backend application that creates the gRPC server is complete but is lonely with no one to talk to – let’s fix that and create the multi-platform Flutter frontend/ application! Similarly to the backend application, the frontend/ application can be created as a project that targets any platform we would like (we know we will need Linux). 

Since this is the face of the system we will need to implement a GUI that statefully updates based on the state of the LED as updates are sent to and received from the backend gRPC server stream. Let’s start by setting up the business logic in the LedStatusViewModel:

import 'dart:async';


import 'package:flutter/material.dart';
import 'package:unleashing_grpc/unleashing_grpc.dart';


class LedStateViewModel extends ChangeNotifier {
 final MachineControlGrpcClient _client = MachineControlGrpcClient(
   hostname: 'raspberrypi4.local',
 );


 /// The current LED state.
 bool get ledState => _ledState;
 bool _ledState = false;


 LedStateViewModel() {
   _streamLedState();
 }


 /// Set the LED state via gRPC.
 void setLedState(final bool value) async {
   // Note: we are not updating the local state here, as we are listening to
   // the stream of LED states (we do not want to accidentally get out of sync).
   await _client.setLedState(value);
 }


 void _streamLedState() {
   _client.streamLedState().listen((final LedState state) {
     if (state.ledOn == _ledState) {
       return;
     }


     _ledState = state.ledOn;
     notifyListeners();
   });
 }
}

Although the logic itself is simple and we are just calling into our custom gRPC client, there are a few pieces worth calling out. The first is the hostname the gRPC client is actually connected to – it is extremely important that the host the client attempts to connect to is the same host the server is serving on. If the hostnames differ, the server will still be lonely as the client will not be able to communicate with the server. As of now we are connecting to raspberrypi4.local, which is an IP alias for our target device. Since this is a local alias, the client and server must also be on the same network. Ideally, this would not be a hardcoded value – it should be a build-time configurable value (–dart-define=HOSTNAME=”myhost”) that can be read in via the String.fromEnvironment paradigm.

The other interesting piece of the LedStateViewModel class is the extension – ChangeNotifier. By extending ChangeNotifier, we end up with the ability to create StatelessWidgets that can automatically rebuild when a subscribed value is updated and notifyListeners is called. To tie together the view model logic and the view logic we need to create and return a ChangeNotifierProvider and use the Selector widget to only rebuild when the ledState changes:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';


import '../view_model/led_state_view_model.dart';


class LedStateView extends StatelessWidget {
 const LedStateView({super.key});


 @override
 Widget build(final BuildContext context) {
   return ChangeNotifierProvider<LedStateViewModel>(
     create: (final _) => LedStateViewModel(),
     child: _LedStateView(),
   );
 }
}


class _LedStateView extends StatelessWidget {
 @override
 Widget build(final BuildContext context) {
   // Read in the provided [LedStateViewModel] but do not rebuild at-will.
   final LedStateViewModel viewModel = context.read<LedStateViewModel>();


   return Scaffold(
     appBar: AppBar(
       title: const Text('LED State'),
       centerTitle: true,
       backgroundColor: Colors.orange,
     ),
     body: Column(
       mainAxisAlignment: MainAxisAlignment.center,
       children: <Widget>[
         Expanded(
           child: Center(
             // Only rebuild whenever the [ledState] changes.
             child: Selector<LedStateViewModel, bool>(
               selector: (final _, final LedStateViewModel viewModel) =>
                   viewModel.ledState,
               builder: (final _, final bool ledState, final __) {
                 return Column(
                   mainAxisAlignment: MainAxisAlignment.center,
                   children: <Widget>[
                     Text(
                       'LED State: ${ledState ? 'On' : 'Off'}',
                       style: TextStyle(
                         color: ledState ? Colors.green : Colors.red,
                       ),
                     ),
                     const SizedBox(height: 20),
                     ElevatedButton(
                       onPressed: () =>
                           viewModel.setLedState(!viewModel.ledState),
                       child:
                           Text(viewModel.ledState ? 'Turn Off' : 'Turn On'),
                     ),
                   ],
                 );
               },
             ),
           ),
         ),
       ],
     ),
   );
 }
}

There are many ways to create a stateful UI in Flutter, but the provider is especially nice in this case because we are listening to updates that a change occurred. Another route would be to create a StatefulWidget and set the ledState after the button is pressed, but that would mean we always assume the state was updated successfully. If you know anything about hardware, it is safe to assume you should not assume.

Ready to Run with it

The code provided in this blog series is a synopsis of public code written for the example use case posed in the introduction. If you have been following along and creating your Flutter library, frontend, and backend applications, I challenge you to take it a step further and use Dependency Injection (DI) to get your backend in a state where you can mock out the MachineControl class and run the headless backend application on your local machine. If you really want to take your PoC to the next level, start exploring Yocto and build your applications using the meta-flutter layers. If you opt to explore Yocto for the first time, I recommend starting with the “Introduction to Embedded Linux YouTube series put out by DigiKey.

Another option is to explore the public source code written for this blog. The Flutter library and applications can be found here. Feel free to explore all the code, fork the repository, or open a pull request to make the example better! Both the frontend and backend applications can be run on Windows, MacOS, or Linux. Please see the README for instructions on running each example. 

If you happen to have a Raspberry Pi 4b sitting around, check out the machine-control-os to get the full experience of this PoC. 

Conclusion

With the combination of beautiful Flutter UIs, trivial gRPC support, and Flutter being supported on embedded Linux, the combination of Flutter and gRPC is quickly becoming a SpinDance go-to for embedded systems requiring both UI and machine control. Feel free to explore and use the following repositories and reach out if you have any questions or are interested in talking about how Flutter and gRPC might apply to your use case!

https://github.com/lapumb-spindance/unleashing-grpc

https://github.com/lapumb-spindance/machine-control-os