セツゾク

Hayato Shimada Portfolio & Blog

Site cover image

🗃️ Blazor FTP to PLC

💡
画像はChatGPTで生成されています。

PLCのSDカードにブラウザ経由でアクセスしたい!

PLC内部のディレクトリを操作したい、というのはFA系ではあるあるな要望です。

PLCは大抵FTPサーバー機能を有していますが、最近のブラウザはFTP機能が使えません。

なので「ftp://192.168.***.***/filepath」のようなリンクを張っても、画像を表示したり、ダウンロード出来たりしません。

FTPってセキュアじゃないので、いろんなブラウザから機能削除されております。

でもまあ要望だから仕方ないよね。。。

ブラウザ機能を使った参照は厳しいのでFluentFTPを利用して、ブラウザ経由でPLCのローカルファイルを操作していきましょう。

環境

Blazor Server

.NET 8.0

FluentFTP 52.0.0

Visual Studio 2022

FluentFTPとは

FluentFTPを利用する以前は、バッチファイルを生成してコマンドプロンプト経由で実行する方式を取っていました。

# ftp_command.bat

open 192.168.250.**
KV
pass
cd /0_CARD/data
ls
bye

lsの実行結果が返されるので、それを元にテーブルを生成したり、delete, get, putといったコマンドを使って操作したりといった使い勝手でしたが、FluentFTPによってバッチファイルを作成しなくてもよくなり、コードがすっきりします。

フォルダ内の一覧取得

GetListingで、List<string>形式でファイル名を取得できます。

下記のコードは画像のみに絞って抽出しています。

client.Credentialsでログイン処理を行い、client.Connectでftp接続を開始します。

KV-8000の場合、client.Connect中は他のFTP処理を受け付けません。

private readonly string ftpUserName = "KV";
private readonly string ftpPassword = "pass";
private FtpClient client = new FtpClient("192.168.***.***");

public List<string> GetFtpFileListAsyncFluent()
{
    var files = new List<string>();

    client.Credentials = new System.Net.NetworkCredential(ftpUserName, ftpPassword);
    client.Connect();

    foreach (var item in client.GetListing("/0_CARD/data/"))
    {
        if (item.Type == FtpObjectType.File && item.Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase))
        {
            files.Add(item.FullName);
        }
    }

    client.Disconnect();

    return files;
}

サンプルコード

特定 ディレクトリ(KV-8000 SDカード dataフォルダ/0_CARD/data/)内の画像を取得し、ファイル名規則に従って、クラスを作成して、テーブル表示させるためのコードです。

filesがテーブル用のリストになります。

private readonly string ftpServer = "192.168.***.***";
private readonly string ftpUserName = "KV";
private readonly string ftpPassword = "pass";
private FtpClient client = new FtpClient("192.168.***.***");

public List<FtpFile> GetFilesAsync()
{
    var files = new List<FtpFile>();

    try
    {
        // FTPコマンドを実行してファイルリストを取得
        var fileList = GetFtpFileListAsyncFluent();

        // ファイル名を解析して FtpFile オブジェクトに変換
        foreach (var file in fileList)
        {
            files.Add(ParseFileName(file));
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error retrieving files: {ex.Message}");
    }

    return files;
}

public List<string> GetFtpFileListAsyncFluent()
{
    var files = new List<string>();

    client.Credentials = new System.Net.NetworkCredential(ftpUserName, ftpPassword);
    client.Connect();

    foreach (var item in client.GetListing("/0_CARD/data/"))
    {
        if (item.Type == FtpObjectType.File && item.Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase))
        {
            files.Add(item.FullName);
        }
    }

    client.Disconnect();

    return files;
}

private FtpFile ParseFileName(string fileName)
{
    try
    {
        // パスからファイル名だけを取得
        string fileNameOnly = Path.GetFileName(fileName);

        // '_' で分割して各要素を抽出
        var parts = fileNameOnly.Split('_');

        // パーツ数が正しいか確認
        if (parts.Length == 4)
        {
            return new FtpFile
            {
                FileName = fileNameOnly,
                PatternId = parts[0],
                Location = parts[1],
                Type = parts[2],
                ImageNumber = parts[3].Replace(".jpg", "")
            };
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error parsing file name '{fileName}': {ex.Message}");
    }

    // パースに失敗した場合、空のオブジェクトを返す
    return new FtpFile
    {
        FileName = fileName,
        PatternId = string.Empty,
        Location = string.Empty,
        Type = string.Empty,
        ImageNumber = string.Empty
    };
}

public class FtpFile
{
    public string FileName { get; set; } = string.Empty;
    public string PatternId { get; set; } = string.Empty;
    public string Location { get; set; } = string.Empty;
    public string Type { get; set; } = string.Empty;
    public string ImageNumber { get; set; } = string.Empty;
}

アップロード処理

UploadFile APIを利用します。FTPなので、localFilePathだけの指定で良いのが強い。

環境によってはエラーが発生しそうなので、Streamに変換してもよさそう。DownloadStreamというAPIも用意されています。

clientの定義はテーブル取得と同様です。

public void UploadFileAsync(string remoteFileName string localFilePath)
{
    try
    {
        // FTPサーバー上のファイルパスを構築
        string remoteFilePath = $"/0_CARD/data/{remoteFileName}";

        client.Credentials = new System.Net.NetworkCredential(ftpUserName, ftpPassword);
        client.Connect();

        // アップロード処理
        var result = client.UploadFile(localFilePath, remoteFilePath);

        if (result != FluentFTP.FtpStatus.Success)
        {
            throw new Exception($"Failed to upload file to: {remoteFilePath}");
        }

        client.Disconnect();


    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error uploading file: {ex.Message}");
    }
    finally
    {
    }

}

ダウンロード処理

FluentFTPのドキュメントはアップロードと同様です。

Blazorのファイルダウンロードはjavascripの利用が推奨されているので、ダウンロード用にスクリプトを記述しておきます。

// _Host.cshtml
@page "/"
@namespace BlazorApp1.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = "_Layout";
}

<component type="typeof(App)" render-mode="ServerPrerendered" />

<script>
    function saveAsFile(filename, bytesBase64) {
        const link = document.createElement('a');
        link.download = filename;
        link.href = 'data:application/octet-stream;base64,' + bytesBase64;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    }
</script>

Blazor側ではこのように記述します。

処理の流れは下記の通りです。

ボタンクリック等→FTP要求→memorystreamで取得→javascriptに投げてダウンロード

// ダウンロードページのコード
// ボタンを押すとダウンロードされるイメージ
@code {

	private async Task DownloadFile(string remoteFileName)
	{
	    remoteFileName = "/0_CARD/data/" + remoteFileName;
	
	    // ファイルをメモリーストリームとして取得
	    using var fileStream = await FtpService.DownloadFileAsStreamAsync(remoteFileName);
	
	    if (fileStream != null)
	    {
	        // JavaScript を使ってファイルをダウンロード
	        var fileBytes = fileStream.ToArray();
	        await JsRuntime.InvokeVoidAsync("saveAsFile", "example.jpg", Convert.ToBase64String(fileBytes));
	    }
	}
}
// サービス側の処理

public async Task<MemoryStream> DownloadFileAsStreamAsync(string remoteFileName)
{
    var memoryStream = new MemoryStream();

    try
    {
        client.Connect();

        // Stream を使用して FTP サーバーからファイルを取得
        using (var stream = client.OpenRead(remoteFileName))
        {
            await stream.CopyToAsync(memoryStream);
        }

        client.Disconnect();
        // メモリーストリームの位置をリセット
        memoryStream.Position = 0;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error downloading file: {ex.Message}");
    }

    return memoryStream;
}

プレビューの表示

今回はBootstrapモーダルでの表示をしてみました。

テーブル形式でファイル名の横に並べてみても良いと思いますが、ダウンロード時間とメモリ使用量が馬鹿にならないと思ったので、一枚ずつ表示するよう実装しています。

// プレビュー表示ページ
<ImageModal IsVisible="@IsModalVisible" ImageStream="@ImageStream" OnClose="CloseModal" />
@code {
	private bool IsModalVisible;
	private MemoryStream? ImageStream;
	private FileUploadModel fileUploadModel = new();
	
	private async Task ShowModal(string remoteFileName)
	{
	    remoteFileName = "/0_CARD/data/" + remoteFileName;
	
	    // ファイルをダウンロード 
	    ImageStream = await FtpService.DownloadFileAsStreamAsync(remoteFileName);
	
	    // モーダルを表示
	    IsModalVisible = true;
	}
	
	private void CloseModal(bool isVisible)
	{
	    IsModalVisible = isVisible;
	}
}
// モーダル表示(ImageModal.cs)
@inherits LayoutComponentBase

<div class="modal fade show" tabindex="-1" style="display: @(IsVisible ? "block" : "none")" aria-hidden="@(IsVisible ? "false" : "true")">
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Image Preview</h5>
                <button type="button" class="btn-close" @onclick="CloseModal" aria-label="Close"></button>
            </div>
            <div class="modal-body text-center">
                @if (!string.IsNullOrEmpty(Base64Image))
                {
                    <img src="@Base64Image" alt="Preview" class="img-fluid" />
                }
                else
                {
                    <p>Loading...</p>
                }
            </div>
        </div>
    </div>
</div>

@code {
    [Parameter]
    public bool IsVisible { get; set; }

    [Parameter]
    public EventCallback<bool> OnClose { get; set; }

    [Parameter]
    public MemoryStream? ImageStream { get; set; }

    private string? Base64Image;

    protected override async Task OnParametersSetAsync()
    {
        if (ImageStream != null)
        {
            var imageBytes = ImageStream.ToArray();
            Base64Image = $"data:image/jpeg;base64,{Convert.ToBase64String(imageBytes)}";
        }
    }

    private async Task CloseModal()
    {
        IsVisible = false;
        await OnClose.InvokeAsync(false);
    }
}
// サービス側の処理(ダウンロードと同じ)
public async Task<MemoryStream> DownloadFileAsStreamAsync(string remoteFileName)
{
    var memoryStream = new MemoryStream();

    try
    {
        client.Connect();

        // Stream を使用して FTP サーバーからファイルを取得
        using (var stream = client.OpenRead(remoteFileName))
        {
            await stream.CopyToAsync(memoryStream);
        }

        client.Disconnect();
        // メモリーストリームの位置をリセット
        memoryStream.Position = 0;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error downloading file: {ex.Message}");
    }

    return memoryStream;
}

まとめ

枯れた技術ですが、シンプルなのがFTPの良いところですね。

FluentFTPで更にシンプルになりました。

その分脆弱なので、実際の利用シーンではせめてパスワードを強固にするとか、環境変数としてコード内に接続情報を入れないようにするとか最低限の対策は必要です。

ダウンロード, アップロードファイルの無害化処理等も必要かもしれない。

PLCがその辺り進化してくると良いと思うのですが。

KEYENCEのクラウド市場参入に期待ですね。

※今回は同期処理で実装していますが、非同期でもいけるっぽいですね。

FTPSで気持ちセキュアに通信できないかも試してみたいので、余裕があれば記事にします。