オリジナルのスマートスピーカーを作ってみる 6 GUI作成
この記事は?
この記事は オリジナルのスマートスピーカーを作ってみる シリーズの続き「その6」です。
アプリの起動を GUI から操作したい
以下の操作を GUI からできるようにしたいです。
- アプリの起動状況の確認
- アプリの起動/停止
- ログ出力の閲覧
- アプリの設定ファイルの閲覧
ブラウザからできるようにしたいので Web アプリを作成します。
本体のアプリと Web アプリは同一ホスト (ラズパイ) で同居させる前提で、Web アプリを作成します。
こちらもまた書き慣れた C# で作成しようと思います。
以下、開発環境などです。
開発環境
開発マシン OS | Windows 11 (x64) |
アプリケーションプラットフォーム | .NET 8 |
言語 | C# 12 |
IDE | Visual Studio Community 2022 |
Web アプリのホスト
製品名 | Raspberry Pi 5 |
OS | Raspberry Pi OS (ベースは Debian GNU/Linux 12 (bookworm)) |
CPU アーキテクチャ | aarch64/arm64 |
メモリ | 8GB |
アプリケーションのプロジェクトは ASP.NET Core MVC を選択しました。
ASP.NET Core MVC の公式ドキュメントはこちら。
このアプリのビルド成果物ですが、独立ですぐに利用可能な Web サーバーとなっています。
HTTPサーバー機能部分の名前は「Kestrel」と言うそうです。
今回も実行ファイルの形式は SCD(自己完結型。実行環境に.net のインストールは必要ない)を取り、発行するときのコマンドは以下のようになりました。
#ラズパイ向けの実行ファイルを発行する
dotnet publish -c Release -r linux-arm64 --self-contained true
URLを設定する方法 はいくつかあり、今回は appsettings.json から設定を行いました。
appsettings.json に以下を追記します。
...
"Kestrel": {
"Endpoints": {
"MyHttpEndpoint": {
"Url": "http://<設定したい IP>:<設定したいport番号>"
}
}
}
}
実行コマンドは以下の通りです。
chmod +x ./my_web_app
./my_web_app
以下のように単一ですぐにWebサイトを立ち上げることができます。
(内容は初期テンプレートのサイトです。)
このWebアプリを systemd 対応して常駐化させたいので、systemd ユニットファイルを作成しました。内容は以下の通りです。
[Unit]
Description=This service is a web service of my app.
[Service]
Type=simple
ExecStart=/var/www/my_web_app_directory/my_web_app
WorkingDirectory=/var/www/my_web_app_directory
# サービスが終了してしまったとき自動的に再起動する
Restart=always
[Install]
WantedBy=multi-user.target
Web アプリから本体アプリを操作する
本体アプリは以下のコマンドで操作しています。
これらを Web アプリから実行できるようにします。
# サービスの起動状況を確認
$ systemctl status my_app.service
# サービスを開始
$ systemctl start my_app.service
# サービスを停止
$ systemctl stop my_app.service
以下の記事のコーディング例が参考になりました。
これはブラウザサイドから呼び出すための API となります。
コードの一部を以下に紹介します。
using Microsoft.AspNetCore.Mvc;
using System.Net;
public class MyAppActivationController : LoggableControllerBase
{
public MyAppActivationController(ILogger<MyAppActivationController> logger) : base(logger) { }
[HttpPost]
public async Task<JsonResult> Activate()
{
var json = new ResultAPIModel();
var result = new CustomJsonResult(json);
try
{
var commandResult = await MyAppActivator.Activate(GetLogger());
if(commandResult?.Success() == true)
{
json.SetSuccess();
result.SetStatusCode(HttpStatusCode.OK);
return result;
}
LogError("commandResult is failure.", commandResult);
return result;
}
catch (Exception ex)
{
json.SetException(ex);
LogError(ex);
result.SetStatusCode(HttpStatusCode.InternalServerError);
return result;
}
}
...
public class ResultAPIModel
{
public bool success { get; private set; } = false;
public string message { get; private set; } = string.Empty;
public void SetSuccess()
{
success = true;
message = string.Empty;
}
public void SetException(Exception ex)
{
success = false;
message = ex.ToString();
}
}
public class MyAppActivator
{
public async static Task<BashCommandResult?> Activate(ILogger logger)
{
BashCommandResult? commandResult = null;
var command = "sudo systemctl start my_app.service";
var commandSource = new BashCommandSource(command);
try
{
commandResult = await BashCommandExecutor.Execute(logger, commandSource);
}
catch (Exception ex)
{
logger.MultiError(ex, $"Command '{commandSource.Command}' failed.");
}
return commandResult;
}
using System.Diagnostics;
public static class BashCommandExecutor
{
public static Task<BashCommandResult?> Execute(
ILogger logger,
BashCommandSource commandSource)
{
var source = new TaskCompletionSource<BashCommandResult?>();
var startInfo = new ProcessStartInfo
{
FileName = "bash",
Arguments = $"-c \"{commandSource.Command}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (!string.IsNullOrEmpty(commandSource.WorkingDirectory))
startInfo.WorkingDirectory = commandSource.WorkingDirectory;
var process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = true
};
process.Exited += (sender, args) =>
{
var result = new BashCommandResult(commandSource);
var errorLines = process.StandardError.ReadToEnd();
result.ErrorOutput = errorLines;
var lines = process.StandardOutput.ReadToEnd();
result.StandardOutput = lines;
result.SetExitCode(process.ExitCode);
source.SetResult(result);
process.Dispose();
};
process.Start();
return source.Task;
}
}
public class BashCommandSource
{
public string Command { get; }
public IEnumerable<int> NormalExitCodeList { get; set; } = [0];
public string? WorkingDirectory { get; set; } = null;
public BashCommandSource(string command)
{
Command = command.Replace("\"", "\\\"");
}
}
public class BashCommandResult
{
public BashCommandSource CommandSource { get; }
public int? ExitCode { get; private set; }
public string StandardOutput { get; set; } = string.Empty;
public string ErrorOutput { get; set; } = string.Empty;
public BashCommandResult(BashCommandSource source)
{
CommandSource = source;
}
public void SetExitCode(int code)
{
ExitCode = code;
}
public bool Success() => ExitedNormally();
private bool ExitedNormally()
{
if (!ExitCode.HasValue) return false;
if(CommandSource.NormalExitCodeList.Contains(ExitCode.Value))
return true;
return false;
}
}
管理画面イメージ
最終的に以下のような管理画面を作成しました。
本体アプリのダッシュボード
本体アプリのログ
以下の記事に続く
オリジナルのスマートスピーカーを作ってみる 7 デプロイ自動化
Discussion