はじめに
前回、こちらの記事で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
$ cd scripts/migrations
$ migrate create -ext sql -format 2006010215 add_firmware_version_to_users
upとdownのsqlファイルが作成されるので、下記のように記載。
// 2024040407_add_firmware_version_to_users.up.sql
ALTER TABLE users
ADD COLUMN firmware_version VARCHAR(20) DEFAULT '1.0.0';
// 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
カラムのデータを読み書きできるようにする。
// 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メソッドを定義する。
// 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″と関数内に新しいファームウェアバージョンを直書きしたが、将来的には新しいファームウェア情報を格納するテーブルを用意してデータベースから呼ぶようにする。
// 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定義を使用することが重要。これにより、双方が同じプロトコルで通信し、正しいデータ形式で情報を交換できるようになる。
// 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コードを生成する。
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を呼び出すために使用する。
// 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 に関連するファームウェア更新情報をリアルタイムで受け取ることができる。
// 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する。
// 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アップデートのメッセージ表示
データの取得: firmwareUpdateProvider
をwatch
して、このプロバイダーが提供するデータに基づいてTextウィジェットを更新する。
Firmwareアップデートがある場合には、ファームウェアバージョンを記載してTextメッセージを表示し、すでにユーザーのFirmwareが最新の場合には、お知らせを表示しない。
// 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"),
),
),
);
}
}
動作確認
このような感じで、無事新しいファームウェアバージョンがある場合に、そのお知らせをホーム画面トップに表示することができた。
サーバー側のログにも、アプリ側からファームウェアアップデートがあるかどうかのリクエストが飛んでいることがわかる。
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アプリの強制アップデートを実装した記事がこちら
コメント