🦷

オリジナルのスマートスピーカーを作ってみる 6 GUI作成

2024/04/11に公開

この記事は?

この記事は オリジナルのスマートスピーカーを作ってみる シリーズの続き「その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」と言うそうです。
https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/servers/kestrel?view=aspnetcore-8.0

今回も実行ファイルの形式は SCD(自己完結型。実行環境に.net のインストールは必要ない)を取り、発行するときのコマンドは以下のようになりました。

powershell
#ラズパイ向けの実行ファイルを発行する
dotnet publish -c Release -r linux-arm64 --self-contained true

URLを設定する方法 はいくつかあり、今回は appsettings.json から設定を行いました。
appsettings.json に以下を追記します。

appsettings.json
...
  "Kestrel": {
    "Endpoints": {
      "MyHttpEndpoint": {
        "Url": "http://<設定したい IP>:<設定したいport番号>"
      }
    }
  }
}

実行コマンドは以下の通りです。

bash on raspi
chmod +x ./my_web_app
./my_web_app

以下のように単一ですぐにWebサイトを立ち上げることができます。
(内容は初期テンプレートのサイトです。)

このWebアプリを systemd 対応して常駐化させたいので、systemd ユニットファイルを作成しました。内容は以下の通りです。

my_web_app.service
[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 アプリから実行できるようにします。

systemd service の操作
# サービスの起動状況を確認
$ systemctl status my_app.service
# サービスを開始
$ systemctl start my_app.service
# サービスを停止
$ systemctl stop my_app.service

以下の記事のコーディング例が参考になりました。
https://jackma.com/2019/04/20/execute-a-bash-script-via-c-net-core/
この記事を参考に 「本体アプリを起動する API」を作成しました。
これはブラウザサイドから呼び出すための API となります。
コードの一部を以下に紹介します。

MyAppActivationController.cs
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;
        }
    }
...
ResultAPIModel.cs
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();
    }
}
MyAppActivator.cs
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;
    }
BashCommandExecutor.cs
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;
    }
}
BashCommandSource.cs
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("\"", "\\\"");
    }
}
BashCommandResult.cs
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