方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Flutter × Go × gRPC】OTAアップデート:Firmware更新をgRPCでアプリ通知

この記事は約17分で読めます。

はじめに

前回、こちらの記事でESP32のOTAアップデートに関して、デバイス側のみ実装した。今回は新しいFirmwareを開発者がアップロードした時に、ユーザーに対してアプリ通知行う部分を実装する。

Firmwareアップデートのアプリ通知

全体の流れ

AWS s3のfirmwareディレクトリに新しいFirmwareバイナリを開発者がバージョン指定してアップロードする。

各ユーザーのFirmwareのバージョン(=データベースのUserテーブルのfirmware_versionカラムの値)と開発者がアップロードした最新のFirmwareバージョンを比較し、異なる場合にFlutterアプリに「新しいFirmware(Firmwareバージョン)をインストール可能です」と通知する。

サーバー(Go)側の実装

ユーザーのファームウェアバージョンを管理(カラムと構造体)

Userテーブルに新しくFirmwareのバージョンを格納する。

  • カラム名: firmware_version
  • : VARCHAR
  • 長さ: バージョン番号の構造によるが、一般的にVARCHAR(10)からVARCHAR(20)の範囲で十分な場合が多い。
  • 初期値: 1.0.0
ShellScript
$ cd scripts/migrations
$ migrate create -ext sql -format 2006010215 add_firmware_version_to_users

upとdownのsqlファイルが作成されるので、下記のように記載。

Go
// 2024040407_add_firmware_version_to_users.up.sql
ALTER TABLE users 
  ADD COLUMN firmware_version VARCHAR(20) DEFAULT '1.0.0';
Go
// 2024040407_add_firmware_version_to_users.down.sql
ALTER TABLE users
  DROP COLUMN firmware_version;

migration適用して、fimware_versionカラムをUserテーブルに追加した。

User構造体にFirmwareVersionフィールドを追加

usersテーブルのfirmware_versionカラムに対応するフィールド。User構造体を通じてfirmware_versionカラムのデータを読み書きできるようにする。

Go
// user_db.go
type User struct {
	ID                         int                  `db:"id" json:"id"`
	UID                        string               `db:"uid" json:"uid"`
	DeviceID                   types.NullString     `db:"device_id" json:"device_id"`
	// ... その他のフィールド ...
	SleepTransitionTime        types.NullInt64      `db:"sleep_transition_time" json:"sleep_transition_time"`
	HealthDataIntegrationStatus bool                `db:"healthdata_integration_status" json:"healthdata_integration_status"`
	FirmwareVersion            string            `db:"firmware_version" json:"firmware_version"` // ファームウェアバージョン
}

notification.protoファイルに、ファームウェア更新の利用可能通知を追加

ユーザーに新しいファームウェアが利用可能であることを知らせるための通知メッセージを定義し、この新しいメッセージタイプを使うRPCメソッドを定義する。

Go
// notification.proto

syntax = "proto3";

package protos;
option go_package = "github.com/EarEEG-dev/clocky_be/pb";

import "shadow.proto";

// 既存の定義...
message StreamRequest {
  int32 userId = 1; // user id
}

message FirmwareUpdateAvailableResponse {
  int32 userId = 1;
  string message = 2;
  string new_firmware_version = 3;  // 利用可能な新しいファームウェアのバージョン
}

service NotificationService {
  rpc Listen(StreamRequest) returns (stream StreamResponse);
  // ファームウェア更新通知用の新しいメソッド
  rpc ListenFirmwareUpdates(StreamRequest) returns (stream FirmwareUpdateAvailableResponse);
}

.protoファイルのメッセージ定義でサーバーのレスポンス側にstreamキーワードを使用することで、サーバーストリーミングRPCを実装できる。

これにより、クライアントが一度リクエストを送信した後、サーバー側で変更があるたびにクライアントにデータを”プッシュ”する形で連続的に情報を送信することが可能になる。サーバー側で新しいファームウェアアップデートが利用可能になった際に、即座にその情報をクライアントにプッシュで送るようにするため、サーバーストリーミングRPCとする。

サーバーストリーミングRPCの詳細に関してはこちら。

メッセージとrpcメソッドを.protoファイルに記述終えたら、protocコマンドを使って、.protoファイルからGoのソースコードを生成する。

gRPCのListenメソッドにFirmware Version比較ロジックを追加

.protoファイルで作成したrpcメソッドをコンパイルして作成されたListenFirmwareUpdates関数を用いて、新しいFirmwareバージョンの通知部分を実装する。

新しいFirmwareのバージョンを固定で持ち、Listenメソッド内で、新しいFirmwareのバージョンとユーザーの現在のバージョンを比較して通知を送信するロジックを追加。

今回は、const newFirmwareVersion = “v1.0.1″と関数内に新しいファームウェアバージョンを直書きしたが、将来的には新しいファームウェア情報を格納するテーブルを用意してデータベースから呼ぶようにする。

Go
// grpc_server.go

type server struct {
	pb.UnimplementedHelloServiceServer
	pb.UnimplementedNotificationServiceServer
	grpcServer *GRPCServer
}

func (s *server) ListenFirmwareUpdates(req *pb.StreamRequest, stream pb.NotificationService_ListenFirmwareUpdatesServer) error {
	uid = req.UserId

	log.Printf("received listen firmware update request from user: %s", uid)

	// ファームウェアバージョンを取得するためにDBからユーザー情報を取得する
	user, err := GetUser(s.grpcServer.db, uid)
	if err != nil {
		return err
	}
	currentVersion := user.FirmwareVersion 
	
	// TODO:直書きしているがDBから読み込めるようにする
    const newFirmwareVersion = "1.0.1"
    
    response := &pb.FirmwareUpdateAvailableResponse{
        UserId:  req.UserId,
        Message: "Your firmware is up to date.",
        NewFirmwareVersion: currentVersion,
    }

    if newFirmwareVersion != currentVersion {
        response.Message = "NEW_FIRMWARE_AVAILABLE"
        response.NewFirmwareVersion = newFirmwareVersion
    }

    if err := stream.Send(response); err != nil {
		return status.Errorf(codes.Internal, "failed to send firmware update response: %v", err)
	}	

    <-stream.Context().Done()
    return stream.Context().Err()
}

アプリ(Flutter)側の実装

notification.protoファイルに、ファームウェア更新の利用可能通知を追加

次にアプリ側の実装に移る。

Flutterアプリ側でも同じnotification.protoファイルに基づいてDartのコードを生成し、サーバー側と通信するためのスタブを作成する必要がある。サーバー側とクライアント側で同じprotobuf定義を使用することが重要。これにより、双方が同じプロトコルで通信し、正しいデータ形式で情報を交換できるようになる。

Dart
// proto/notification.proto
syntax = "proto3";

package protos;
option go_package = "github.com/EarEEG-dev/clocky_be/pb";

message StreamRequest {
  int32 userId = 1; // user id
}

message FirmwareUpdateAvailableResponse {
  int32 userId = 1;
  string message = 2;
  string new_firmware_version = 3;
}

service NotificationService {
  rpc ListenFirmwareUpdates(StreamRequest) returns (stream FirmwareUpdateAvailableResponse);
}

notification.protoファイルがprotosディレクトリにあり、生成されたファイルをlib/grpcに出力したいので、下記コマンドを使用して、.protoファイルからDartコードを生成する。

ShellScript
protoc --dart_out=grpc:lib/grpc -Iprotos protos/notification.proto

protocコンパイラを使ってDartのクライアントコードを生成すると、主に*.pb.dart*.pbjson.dart*.pbgrpc.dartの3種類のファイルが生成される。

  • .pb.dartファイル: protobufメッセージのDartクラス定義が含まれる。メッセージのシリアライズとデシリアライズ、およびメッセージ内のデータへのアクセスを提供する。
  • .pbjson.dartファイル: JSONエンコーディングとデコーディングのロジックを提供する。protobufメッセージをJSON形式に変換したり、JSONからprotobufメッセージへの変換を行う際に使用する。主にAPIとのインターフェースでJSON形式が必要な場合に利用されるが、gRPCでは通常は直接使用しない。
  • .pbgrpc.dartファイル: gRPCサービスクライアントのDartクラス定義が含まれる。.protoファイル内で定義されたサービスごとに、対応するクライアントサイドのスタブが生成される。これらのスタブを使用して、サーバーに定義されたRPCメソッドを呼び出すことができる。メソッドの呼び出し、リクエストの送信、レスポンスの受信、ストリーミング通信の管理など、gRPC通信に必要な機能が含まれている。

今回使用するのは、.pbgrpc.dartファイルのNotificationServiceClientクラス内のlistenFirmwareUpdatesメソッド。listenFirmwareUpdatesメソッドは、クライアントがサーバーの対応するListenFirmwareUpdates RPCを呼び出すために使用する。

Dart
// lib/grpc/notification.pbgrpc.dart
$grpc.ResponseStream<$0.FirmwareUpdateAvailableResponse> listenFirmwareUpdates($0.StreamRequest request, {$grpc.CallOptions? options}) {
  return $createStreamingCall(_$listenFirmwareUpdates, $async.Stream.fromIterable([request]), options: options);
}

gRPCクライアントにFirmware更新をリッスンするメソッドを追加

現在のGrpcServiceクラスには、sayHelloメソッドとlistenNotificationsメソッドが含まれているが、Firmwareの更新をListenするためのメソッドは未定義なので追加する。

StreamRequest インスタンスを作成し、userId を設定してリクエストを構築する。その後、client.listenFirmwareUpdates(request) を呼び出して、サーバーからの FirmwareUpdateAvailableResponse ストリームをリッスンする。yield* キーワードを使用して、このストリームから得られるデータをメソッドの呼び出し元にストリームとして提供する。このメソッドを使用することで、クライアントは指定したユーザー ID に関連するファームウェア更新情報をリアルタイムで受け取ることができる。

Dart
// services/grpc_service.dart
class GrpcService {
  final ClientChannel channel;

  GrpcService({required String host, required int port})
      : channel = ClientChannel(
          host,
          port: port,
          options: const ChannelOptions(
          // 本番環境ではセキュアな接続を使用
            credentials: ChannelCredentials.insecure(),
          ),
        );

  // 既存のメソッド...

  // ファームウェア更新のリッスン用メソッドを追加
  Stream<FirmwareUpdateAvailableResponse> listenFirmwareUpdates(
      int userId) async* {
    final headers = await headersProvider();
    final client = NotificationServiceClient(channel,
        options: CallOptions(
          metadata: headers,
        ));
    final request = StreamRequest()..userId = userId;
    yield* client.listenFirmwareUpdates(request);
  }
}

StreamProviderを定義

gRPCからのレスポンスがStream型なので、データをUIでリアクティブに表示するために、StreamProviderでWrapする。

Dart
// services/grpc_service.dart
final firmwareUpdateProvider =
    StreamProvider<FirmwareUpdateAvailableResponse>((ref) {
  final grpcService = ref.watch(grpcServiceProvider);

  final user = ref.read(userProvider);
  if (user?.uid == null) {
    return const Stream.empty();
  }
  return grpcService.listenFirmwareUpdates(user!.id!);
});

ホーム画面にFirmwareアップデートのメッセージ表示

データの取得: firmwareUpdateProviderwatchして、このプロバイダーが提供するデータに基づいてTextウィジェットを更新する。

Firmwareアップデートがある場合には、ファームウェアバージョンを記載してTextメッセージを表示し、すでにユーザーのFirmwareが最新の場合には、お知らせを表示しない。

Dart
// lib/screens/home/home_tab.dart

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

class HomeTab extends ConsumerStatefulWidget {
  const HomeTab({super.key});

  @override
  _HomeTabState createState() => _HomeTabState();
}

class _HomeTabState extends ConsumerState<HomeTab> {
  
  @override
  Widget build(BuildContext context) {
    final firmwareUpdateAsyncValue = ref.watch(firmwareUpdateProvider);

    return DebugContainer(
      child: Center(
        child: firmwareUpdateAsyncValue.when(
          data: (response) {
            if (response.message == "NEW_FIRMWARE_AVAILABLE") {
              return Text("新しいファームウェア(${response.newFirmwareVersion})がダウンロード可能です");
            } else {
              // ファームウェアが最新の場合、何も表示しない
              return SizedBox.shrink();
            }
          },
          loading: () => CircularProgressIndicator(),
          error: (error, stack) => Text("エラーが発生しました: $error"),
        ),
      ),
    );
  }
}

動作確認

このような感じで、無事新しいファームウェアバージョンがある場合に、そのお知らせをホーム画面トップに表示することができた。

Screenshot
Screenshot

サーバー側のログにも、アプリ側からファームウェアアップデートがあるかどうかのリクエストが飛んでいることがわかる。

ShellScript
clocky_api_local  | 2024/04/10 10:57:37 received listen firmware update request from user: XXXXXXXXXXXXXXXXXXXXXX
clocky_api_local  | 2024/04/10 10:57:38 sent message to user: 1

次はユーザーが新しいファームウェアインストールをクリックしたら、Deviceshadowを更新して、MQTT通信経由で、実際にESP32に新しいファームウェアをダウンロードする部分を実装する。実装した結果がこちら。

また、同様の仕組みでFlutterアプリの強制アップデートを実装した記事がこちら

コメント

タイトルとURLをコピーしました