Potewoのブログ

電子工作が好きな学生の書く技術系のブログです。

自宅サーバーの紹介

この記事はWMMC Advent Calendar 2024の16日目の記事です。 この記事では我が家で運用している自宅サーバーについて紹介したいと思います。
アドカレがスカスカだったので書くことにしたのですが、最近マイクロマウスの活動が全くできておらず、ネタ切れなのでマイクロマウスはほとんど関係ない話です。

自宅サーバーとは

文字通り、自宅で動かすサーバーのことです。自分でサーバーを立てようとするとどこかの会社のサーバーの一部を借りる方法(AWSやGoogle Cloud Platform、さくらインターネットの諸サービスなど)と自宅で適当なPCを常時起動させてサーバーとする方法があります。前者の方が物理的なPC(サーバー)を管理せずにオンラインで完結できて便利ですが、それなりにお金がかかりがちです。後者の場合、物理的なサーバーをメンテナンスする手間はありますが余っているPCが家にある場合、かかる費用はほぼ電気代のみです。

なぜ自宅サーバーを運用するのか

便利さと楽しさが理由です。このあと自宅サーバーでどんなことをしているのか紹介しますが自宅サーバーでないとできない便利なものもたくさんあります。
また、自分が欲しいものを自分で好きなように作るのは便利であると同時にとても楽しいです。

自宅サーバーで動かしているもの

現在自宅サーバーで動かしているのは以下の5つです。
- homebridge - 自作英単語Webアプリ - 自作英文読み上げWebアプリ - jupyterlab - ROS2学習用の仮想環境 以前動かしたけど今は動かしていないものは以下の4つです。 - adguard - stable diffusion - mjpg-streamer - octoprint

これらについて1つずつ説明していこうと思います。

homebridge

Appleのスマートホーム規格であるHomeKitに対応していないIoTデバイスをHomeKitに対応させるものです。Apple製品を使っていると、HomeKitはOSに組み込まれていて便利ですが、対応製品が少ないのが難点です。メーカーをまたいだIoTの標準規格であるMatterに対応した製品も増えてきてはいますがまだ少ないです。そういった製品をHomeKitで扱えるようにするととても便利です。
また、↓の写真のような扱いやすいダッシュボードも公式が提供してくれているので運用も簡単です。 無駄に二段階認証とかつけられます。

自作英単語Webアプリ

高2くらいのときに既存の英単語帳アプリが気に入らなくて作ったものです。個人的にはとても気に入ってました。最近は新しく英単語を覚えることもほとんどないので使っていないです。
一押しの機能はクイズ機能です。単語が一つずつ出てきてその意味を考えて答え合わせをし、正解できたかやどれだけ早く答えを思いついたかを自分で5段階評価します。クイズに出す問題はその5段階評価を用いて評価が低かったものが優先的に出されるようになっています。さらに絞り込みも充実していて評価が1だったものだけを出題したり過去の正答率が一定以下のものだけにしたり、直近1週間解いたものだけを復習したりできます。 動作も爆速です。

自作英文読み上げアプリ

入力したテキストをGoogleのText-to-Speech APIに渡して読み上げた音声を再生・ダウンロードできるものです。なぜ既存のものではだめだったのか覚えてないです。ただ作ってみたかっただけかもしれません。

jupyterlab

Jupyter Notebookという形式でオンラインでPythonを動かせる環境です。主に外出先でPython環境に手軽にアクセスする目的で立てたサーバーです。気軽にスクリプトを動かしてみたり、簡単なシミュレーションのパラメーターを調整するのに使っています。ブラウザからも操作できますが、iPadにJuno Connectというアプリを入れてiPadからもアクセスしやすくしています。ひたすら値を入れて動かしてを繰り返すだけのパラメーター調整を電車でやると効率的です。

ROS2学習用の開発環境

普段大学で使っているのはWindows PCなのでROS2を動かすためにUbuntu環境を立ち上げるのは少々面倒です。そこでオンラインでどこからでもアクセスできるROS2の環境があると便利なので作りました。gazeboのようなシミュレーションを動かすのは難しいですが、簡単なノードのプログラムを動かしてみるくらいはできます。

adguard

広告ブロックを行えるようにするためのサーバーです。 インストールの時点で苦労したり、ブロックしてほしいものがブロックできなかったりその逆だったりで辞めました。(あまりにうっとうしい広告が多くなってきたので始めようとしたのですが、そもそも広告をブロックすること自体がどうなんだという気持ちもあり...)

stable diffusion

流行りの(流行っていた?)画像生成AIです。OpenVINOというものを使用すれば高価なGPUがなくともIntel CPUで画像生成できると聞いてやろうとしたのですが、結局うまく生成できなかったので止めました。

mjpg-streamer、octoprint

mjpg-streamerはwebカメラの映像を配信するサーバー、octoprintは3Dプリンターをオンラインで使えるようにするサーバーです。以前はWi-Fiにつなげない3Dプリンターしかもっていなかったのですが、最近Wi-Fiにつなげられてカメラも付けられる3Dプリンター(Ender3 V3 KE)を買ったので必要なくなりました。

セットアップ

家で使われなくなったMac mini(2014)をサーバーとして使用しています。Intel(R) Core(TM) i5-4278U CPU @ 2.60GHz、メモリ8GBのモデルです。ノートPC用のCPUのようで低消費電力だと思います。もとから入っているmacOSは残したまま外付けSSDにUbuntuを入れてサーバーにしています。
また、各サーバープログラムは仮想環境のdocker composeを使用して管理を楽にしています。

最後に

最後まで読んでいただき、ありがとうございました。
この記事を読んで自宅サーバーに興味が出た人はぜひ挑戦してみてください。

明日はYuki先輩の「【初心者向け】マイクロマウス大会 参戦マニュアル」です。お楽しみに!

STM32用のオリジナル書き込みアプリを作る

この記事はWMMC Advent Calendar 2024の9日目の記事です。昨日の記事ははXFA-27先輩の「なんか」でした。

マイクロマウスを作っている方々はSTM32マイコンを使用している方が多く、書き込みにSTM32CubeProgrammerを使っている方も多いと思います。GUIでポチポチするだけで書き込みができる便利なCubeProgrammerですがしょっちゅうクラッシュしたり、ファイル選択が面倒だと思ったことはありませんか?CubeProgrammerにはAPIがあり、APIを通じてCubeProgrammerを操作するアプリを自分で作ることができることができます。この記事では実際に作ったものと作り方を紹介します。

作ったもの

作ったのはMbed Studioという開発環境でビルドしたバイナリファイルを書き込むためのアプリです。
Nucleoを使用している場合はMbed Studio上の書き込みボタンで簡単にプログラムを書き込むことができますが、石単体を載せた基板に別でSTLinkV3MINIEのようなSTLinkを接続するとMbed Studio上で書き込むことができません。(多分) また、Mbed Studioはバイナリファイルのパスがプロジェクトまでのパス/BUILD/ターゲット名/ARMC6/プロジェクト名.binのようになっており、あるプロジェクトのバイナリファイルを書き込んだあとに別のプロジェクトのバイナリファイルを書き込もうとすると階層を上ったり下りたりする必要があり、非常に面倒です。そこでプロジェクト名とターゲット名を選ぶだけで書き込めるアプリを作ることにしました。

作り方

使用した言語・ライブラリ

作ったものを日常的に使うならばTUIなりCUIなりGUIなりで使いやすいガワが必要になるかと思います。自分の場合は使うときの手軽さやわかりやすさ、他人への配布のしやすさを重視してGUIで作ることにしました。 使用した言語はRust、GUI部分には流行りのTauriとReactを使用しました。TauriはUI部分をブラウザの技術を使用し、ロジック部分をRustで書いてGUIアプリを作ることができるライブラリです。通常ブラウザ側からはPCのファイルや他のプログラムとの通信への自由なアクセスはありませんが、その間をRustがとりもつことでなんでもできるようになります。今回RustとTauriを採用した理由はそこそこ簡単にきれいな見た目でクロスプラットフォーム・シングルバイナリなGUIアプリを作ることができるからです。各言語にはGUI用のライブラリがあるかと思いますが、OSのネイティブなUIを使うようなライブラリだとどうしても古臭い見た目になったり思ったような配置にするのが難しかったりすると思います。しかし、ウェブ系の言語でUIを作ると簡単に思ったような見た目にすることができます。見た目をいじるのに使用するCSSはライブラリも豊富で、ライブラリをインポートするだけで見た目をいい感じにしてくれるものがたくさんあります。ChatGPTやGitHub Copilotに書かせる時も元々ネット上に情報が多いのでうまく書かせやすいでしょう(多分)。

また、最後に一番重要なCubeProgrammer APIにアクセスするためのライブラリです。 wervin/stm32cubeprog-rs: Rust API for STM32CubeProgrammer そのままだとSTLinkが見つからなかったときにpanicを起こしてアプリが終了してしまうのでforkして少し変更を加えて使用しました。 Add error handling for no device found in STLink debug connection · Potewo/stm32cubeprog-rs@2dbec0e

コードを書く

ライブラリがそろったら後はライブラリのお作法に沿ってコードを書くのみです。 STM32 CubeProgrammer APIを使用してSTLinkデバイスをリストアップしたりバイナリファイルを書き込むための関数を作り、ファイルを選択するための関数を作り、それらを使ったUI側のコードを書けば完成です。
開発環境や具体的なコマンドはこの記事では省略します。TauriのGetting Startedのページの案内に従ってコマンドを実行していきます。途中で使用する言語やUIフレームワークを聞かれるのでお好みで答えれば大丈夫です。

プロジェクトを作ったらlib.rs、App.tsxを以下のように編集します。

lib.rs

use tauri_plugin_log::{Target, TargetKind};

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg(unix)]
fn get_cube_programmer_path() -> String {
    let home_dir = dirs::home_dir();
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            log::error!("Failed to get home directory");
            return String::new();
        }
    };
    let binding = home_dir.join("STMicroelectronics/STM32Cube/STM32CubeProgrammer");
    let stm32prog_path = binding
        .to_str();
    let stm32prog_path = match stm32prog_path {
        Some(stm32prog_path) => stm32prog_path,
        None => {
            log::error!("Failed to convert STM32CubeProgrammer path to string");
            return String::new();
        }
    };
    stm32prog_path
}

#[cfg(windows)]
fn get_cube_programmer_path() -> String {
    return "C:\\Program Files\\STMicroelectronics\\STM32Cube\\STM32CubeProgrammer".to_string();
}

#[tauri::command]
fn list_stlink_devices() -> Vec<String> {
    let stm32prog_path = get_cube_programmer_path();

    // Load STM32CubeProgmmer API library
    let stm32prog = match stm32cubeprog_rs::STM32CubeProg::new(stm32prog_path) {
        Ok(stm32prog) => stm32prog,
        Err(e) => {
            log::error!("Failed to load STM32CubeProgrammer API library: {e}");
            return Vec::new();
        }
    };
    log::info!("STM32CubeProgrammer API library loaded");

    // Find connected STLinks
    let mut stlinks = match stm32prog.discover() {
        Ok(stlinks) => stlinks,
        Err(e) => {
            log::error!("Failed to discover STLinks: {e}");
            return Vec::new();
        }
    };
    log::info!("STLinks discovered: {}", stlinks.len());
    let mut devices = Vec::new();
    for stlink in stlinks.iter_mut() {
        let serial_number = match stlink.serial_number() {
            Ok(serial_number) => serial_number,
            Err(e) => {
                log::error!("Failed to get STLink serial number: {e}");
                continue;
            }
        };
        log::info!("STLink serial number: {}", serial_number);
        devices.push(serial_number);
    }
    log::info!("STLinks found: {:?}", devices);
    return devices;
    // let devices = list_stlink_devices();
    // devices.iter().map(|d| d.to_string()).collect()
}

#[tauri::command]
fn list_projects() -> Vec<String> {
    log::info!("Listing projects");
    let home_dir = dirs::home_dir();
    log::info!("Home directory: {:#?}", home_dir);
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            log::error!("Failed to get home directory");
            return Vec::new();
        }
    };
    log::info!("Home directory: {:#?}", home_dir);
    let projects_dir = home_dir.join("Mbed Programs");
    log::info!("Projects directory: {:#?}", projects_dir);
    let projects = match std::fs::read_dir(projects_dir) {
        Ok(projects) => projects,
        Err(e) => {
            log::error!("Failed to read projects directory: {}", e);
            return Vec::new();
        }
    };
    log::info!("Projects read");
    let mut project_names = Vec::new();
    for project in projects {
        let project = match project {
            Ok(project) => project,
            Err(e) => {
                log::error!("Failed to read project: {}", e);
                continue;
            }
        };
        log::info!("Project: {:#?}", project);
        let project_name = match project.file_name().into_string() {
            Ok(project_name) => project_name,
            Err(e) => {
                log::error!("Failed to convert project name: {:#?}", e);
                continue;
            }
        };
        log::info!("Project name: {:#?}", project_name);
        project_names.push(project_name);
    }
    log::info!("Projects found: {:#?}", project_names);
    project_names
}

#[tauri::command]
fn list_targets(project: &str) -> Vec<String> {
    let home_dir = dirs::home_dir();
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            log::error!("Failed to get home directory");
            return Vec::new();
        }
    };
    let targets_dir = home_dir.join("Mbed Programs");
    let targets_dir = targets_dir.join(project).join("BUILD");
    log::info!("Targets directory: {:#?}", targets_dir);
    let targets = match std::fs::read_dir(targets_dir) {
        Ok(targets) => targets,
        Err(e) => {
            log::error!("Failed to read targets directory: {}", e);
            return Vec::new();
        }
    };
    let mut target_names = Vec::new();
    for target in targets {
        let target = match target {
            Ok(target) => target,
            Err(e) => {
                log::error!("Failed to read target: {}", e);
                continue;
            }
        };
        let target_name = match target.file_name().into_string() {
            Ok(target_name) => target_name,
            Err(e) => {
                log::error!("Failed to convert target name: {:#?}", e);
                continue;
            }
        };
        target_names.push(target_name);
    }
    target_names
}

use tauri::ipc::InvokeError;

#[derive(Debug, Clone, PartialEq)]
enum FlashError {
    LoadLibraryError(String),
    ConnectionError(String),
    DeviceInfoError(String),
    DownloadError(String),
}

#[tauri::command]
fn write_program(stlink_device: &str, project: &str, target: &str) -> Result<(), FlashError> {
    let stm32prog_path = get_cube_programmer_path();

    // Load STM32CubeProgmmer API library
    let stm32prog = match stm32cubeprog_rs::STM32CubeProg::new(stm32prog_path) {
        Ok(stm32prog) => stm32prog,
        Err(e) => {
            log::info!("Failed to load STM32CubeProgrammer API library: {}", e);
            return Err(FlashError::LoadLibraryError(e.to_string()));
        }
    };
    log::info!("STM32CubeProgrammer API library loaded");

    // Find connected STLinks
    let mut stlinks = match stm32prog.discover() {
        Ok(stlinks) => stlinks,
        Err(e) => {
            let err_msg = format!("Failed to discover STLinks: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        }
    };
    log::info!("STLinks discovered: {}", stlinks.len());
    let stlink = match stlinks
        .iter_mut()
        .find(|stlink| match stlink.serial_number() {
            Ok(serial) => serial == stlink_device,
            Err(_) => false,
        }) {
        Some(stlink) => stlink,
        None => {
            let err_msg = format!("Failed to find STLink with serial number: {}", stlink_device);
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        }
    };
    log::info!("STLink found: {}", stlink_device);

    stlink.set_connection_mode(stm32cubeprog_rs::DebugConnectMode::UnderReset);

    // Flash target
    let home_dir = dirs::home_dir();
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            let err_msg = "Failed to get home directory".to_string();
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        }
    };
    let target_dir = home_dir.join("Mbed Programs");
    let target_dir = target_dir
        .join(project)
        .join("BUILD")
        .join(target)
        .join("ARMC6");
    let target_bin = target_dir.join(format!("{}.bin", project));
    log::info!("Target binary: {:#?}", target_bin);
    let connection_result = stm32prog.connect(stlink);
    if connection_result.is_err() {
        if let Err(e) = connection_result {
            let err_msg = format!("Failed to connect to STLink: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        } else {
            let err_msg = "Failed to connect to STLink".to_string();
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        }
    }
    log::info!("Connected to STLink");
    let device_info = match stm32prog.device_info() {
        Ok(device_info) => device_info,
        Err(e) => {
            let err_msg = format!("Failed to get device info: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::DeviceInfoError(err_msg));
        }
    };
    log::info!("Device info: {:#?}", device_info);
    let download_result = stm32prog.download(target_bin, Some(0x08000000), Some(false), Some(true));
    if download_result.is_err() {
        if let Some(e) = download_result.err() {
            let err_msg = format!("Failed to download binary: {:?}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        } else {
            let err_msg = "Failed to download binary".to_string();
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        }
    }
    log::info!("Binary downloaded");
    let reset_result = stm32prog.reset(stlink);
    match reset_result {
        Ok(_) => {
            log::info!("Reset successful");
        },
        Err(e) => {
            let err_msg = format!("Failed to reset: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        },
    };
    stm32prog.disconnect();
    log::info!("Disconnected from STLink");
    return Ok(());
}

impl Into<InvokeError> for FlashError {
    fn into(self) -> InvokeError {
        InvokeError::from(format!("{:?}", self))
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_shell::init())
        .plugin(
            tauri_plugin_log::Builder::new()
                .target(tauri_plugin_log::Target::new(
                    tauri_plugin_log::TargetKind::LogDir {
                        file_name: Some("logs".to_string()),
                    },
                ))
                .build(),
        )
        .invoke_handler(tauri::generate_handler![
            greet,
            list_stlink_devices,
            list_projects,
            list_targets,
            write_program
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

App.tsx

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import "./App.css";

type FlashingStatus = "idle" | "flashing" | "success" | "fail";
function FlashingStatusView(props: {status: FlashingStatus, error?: string}) {
    if (props.status === "idle") {
      return <span></span>
    } else if (props.status === "flashing") {
      return <span>Flashing...</span>
    } else if (props.status === "success") {
      return <span>Flashing success</span>
    } else if (props.status === "fail") {
      return <>
        <span>Flashing fail</span>
        {props.error && <p>{props.error}</p>}
      </>
    } else {
      console.error("Unknown status: ", props.status);
      return <span>Unknown status: {String(props.status)}</span>
    }
}

function App() {
  const [stlinkDevices, setStlinkDevices] = useState<string[]>([]);
  const [selectedStlinkDevice, setSelectedStlinkDevice] = useState<string>("");
  const [projects, setProjects] = useState<string[]>([]);
  const [selectedProject, setSelectedProject] = useState<string>("");
  const [targets, setTargets] = useState<string[]>([]);
  const [selectedTarget, setSelectedTarget] = useState<string>("");
  const [flashingStatus, setFlashingStatus] = useState<FlashingStatus>("idle");
  const [flashingError, setFlashingError] = useState<string>("");

  async function listStlinkDevices() {
    let devices: string[] = await invoke("list_stlink_devices");
    setStlinkDevices(devices);
    if (devices.length > 0 && !devices.includes(selectedStlinkDevice)) {
      setSelectedStlinkDevice(devices[0]);
    }
  }

  async function listProjects() {
    let projects: string[] = await invoke("list_projects");
    setProjects(projects);
    if (projects.length > 0 && !projects.includes(selectedProject)) {
      setSelectedProject(projects[0]);
    }
  }

  async function listTargets() {
    let targets: string[] = await invoke("list_targets", {
      project: selectedProject,
    });
    setTargets(targets);
    if (targets.length > 0 && !targets.includes(selectedTarget)) {
      setSelectedTarget(targets[0]);
    }
  }

  async function write_program() {
    setFlashingStatus("flashing");
    await invoke("write_program", {
      stlinkDevice: selectedStlinkDevice,
      project: selectedProject,
      target: selectedTarget,
    }).then(() => {
      setFlashingError("");
      setFlashingStatus("success");
      return;
    }).catch((e) => {
      console.error(e);
      setFlashingError(String(e));
      setFlashingStatus("fail")
      return;
    });
  }

  return (
    <main className="container">
      <button onClick={listStlinkDevices}>Reload STLinks</button>
      <label>STLink:
        <select onChange={ e => setSelectedStlinkDevice(e.target.value) } value={selectedStlinkDevice}>
          {stlinkDevices.map((device) => (
            <option key={device}>{device}</option>
          ))}
        </select>
      </label>
      <br />
      <button onClick={listProjects}>Reload Projects</button>
      <label>Project:
        <select onChange={ e => setSelectedProject(e.target.value)} value={selectedProject}>
          {projects.map((project) => (
            <option key={project}>{project}</option>
          ))}
        </select>
      </label>
      <br />
      <button onClick={listTargets}>Reload Targets</button>
      <label>Target:
        <select onChange={ e => setSelectedTarget(e.target.value)} value={selectedTarget}>
          {targets.map((target) => (
            <option key={target}>{target}</option>
          ))}
        </select>
      </label>
      <br />
      <button onClick={write_program}>Flash</button>
      <FlashingStatusView status={flashingStatus} error={flashingError}/>
    </main>
  );
}

export default App;

おおざっぱにコードを解説すると、rust側の関数に#[tauri::command]をつけるとTauriがいい感じにやってくれてそのRust関数をUIを作るJavaScript側から呼び出せるようにしてくれます。Rust側はほとんどstm32cubeprog-rsのExampleにエラーハンドリングを追加して、デバイスの発見と書き込みに関数を分け、書き込むバイナリを探す関数を追加しただけです。
App.tsxの方はそれらの関数を呼び出して結果を表示するReactコードを書くだけです。

できたもの

こんな見た目のアプリができます。
起動も書き込みも爆速でQoLが上がります。(STM32CubeProgrammerって起動ちょっと遅いですよね...)

ConnectとDisconnectも自動でやります。

応用

Rustにはクロスプラットフォーム対応なシリアル通信ができるライブラリもあるので、そういったものを使えば書き込みアプリとシリアルモニタを一体化させることもできます。

最後に

ここまで読んでいただき、ありがとうございます。急いで書いた記事なのでちょっとわかりにくくなってしまいました。後で書き直そうと思います。
明日はTanaport先輩の「クラシックの改良機体の話」です。

SkyRC B6 Neoレビュー

はじめに

こんにちは、WMMC1年のPotewoです。この記事はWMMC Advent Calendar 2023 6日目の記事です。昨日は玉ちゃん先輩のTwitterなんてやめた方がいい理由でした。Twitterはほどほどにしたいですね。

突然ですが皆さんどんなリポ充電器をお使いでしょうか。マイクロマウスをやるにあたってリポ充電器は必須です。弊サークルではHiTechのX1 PocketやX2 Pocketを持っている人が多そうです。僕もマイクロマウスを始めるにあたってその辺りの購入を検討したのですがX1 PocketやX2 Pocketはしょっちゅう持ち運ぶには少し大きいと感じました。そこで小さい充電器を探し、最終的にSkyRCのB6 Neoを購入しました。

探した条件

  • 小さい
  • USB PD入力
  • 電流の大きさを細かく調整できる
  • 6セルのリポも充電できる

小さいのは正義!小ささが第一条件です。
元々パソコンやスマホ、タブレットの充電用にUSB PD充電器を持ち運んでいるのでそれをリポの充電にも使うことで荷物を減らせます。
次にマウスで使うような小さいリポを1C充電するために電流を細かく0.1Aごとに調整できる必要があります。調べた時に時々1Aごとにしか調整できないものがありました。
僕は別のロボコンサークルにも入っていて、そちらでは6セルの大きめのリポを使うのでそちらも充電できるのが望ましいです。

検討したもの

ToolkitRC M4 Pocket

6セルの充電ができなかったので候補から外しました。

ISDT Q6 Nano

XT60入力なのでPDトリガーケーブルと併用すれば使えます。ただボタンの操作性が悪そうで少し大きめなので候補から外しました。

SkyRC B6 Neo

十分小さく、直接USB PDが刺せます。6セルの充電にも対応しています。USB PD入力は最大20V5AですがXT60で200Wまで入れられるそうです。
他と比べて欠点がなさそうだったのでこれを買いました。

レビュー

ここからは実際に買って使った感想等を書こうと思います。

大きさ

とても小さくて気に入っています。少し大きめなスマホ・タブレット充電器くらいの大きさです。

使用感

ボタンはよくあるタクトスイッチのような感触です。2つの上下ボタンと一つの決定ボタンがついており、必要十分で操作しやすいです。

ファンの音

ファンは負荷などに応じて回ったり回らなかったりします。ファンの音の大きさはマックスで回っているときにノートPCのファンがうなってる時と同じくらいです。

使えるUSB PD充電器

試したのは以下の3つです。 - Anker PowerPort III Nano 20W - Anker PowerPort III 65W Pod - Anker Prime Wall Charger (100W, 3 ports, GaN) 65Wのものだけ2回刺さないと起動しませんでした。1回目は刺しても反応せず一旦抜いてすぐに刺し直すと起動しました。他の充電器は問題なく使えました。65Wのものはそもそも普通に充電に使っていても挙動がおかしいことがあったのでそのせいな気はします。

ケーブル

出力側はXT60なので、それを自分のリポに合わせた変換ケーブルを用意する必要があります。自分はリポがRCYで、弊サークルでXT60からRCYのケーブルを失くした時に他の人から借りることができず困ります(今まさに変換ケーブルを失くして困っています)。

直流安定化電源機能

充電器を直流安定化電源のように使う機能があります。ちょっとモーターだけ動かしてみたいときに使えます。ただ、ショート回路や過剰に大きな電流が流れる回路をつなげたときにUSB PD充電器が壊れないかが心配です。あとは出力がXT60のオスなので、バッテリーの代わりとして回路に電源を供給させようとするとメス-メスという他で使う用途がなさそうなケーブルを用意する必要があります。

最後に

やはり小さい充電器は正義です。今のところ、この充電器をとても気に入っています。

読んでいただきありがとうございました。明日はぱわぷろ先輩のETロボコンの紹介だそうです。楽しみですね。