外部機器の状態の確認と表示のベストプラクティス - 『接続中』だけで済ませない設計

· 更新日: · · Windows, 外部機器, 装置連携, 状態管理, UI/UX, 監視

更新履歴(8件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
状態を誰が答えられる問いなのかで分けた図と、接続とセッションの寿命を示す状態遷移図を追加しました。鮮度は機能準備とは独立した軸なので、状態遷移図には入れず、4.5節の判定と組み合わせる形にしています。本文の説明は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
データ鮮度の判定に、受信とは独立した周期の口を追加しました。`Observe`が呼ばれるのは受信したときだけで、そのとき渡される`Reading`はたった今作ったものなので、受信時刻と現在時刻の差はほぼゼロです。この経路で`Stale`になるのは「受信は続いているが連番が進まない」場合だけで、callbackが完全に止まったとき(いちばん知りたい壊れ方)は`Observe`が呼ばれず、画面は最後に計算した`Fresh`のまま固まります。最後に受け取った値の古さを測り直す`Reevaluate`を追加し、budgetより短い周期のタイマーから呼ぶ形にしました。受信スレッドとタイマースレッドが同時に来るため、内部の状態は`lock`で守っています。
表示側のプレゼンターが、画面を閉じている最中の更新で監視ワーカーを巻き添えにしていたのを直しました。`Changed?.Invoke`は監視スレッドから同期的にハンドラーを呼ぶため、コントロールのハンドルが破棄されたあとの`BeginInvoke`が投げる例外が、そのまま`Publish`を通ってワーカー側へ抜けていきます。`Dispose`で購読を外すだけでは、外している最中に始まった呼び出しは止められません。購読を`Control.Disposed`で画面の寿命に紐づけ、破棄中・破棄済みのコントロールへは投げず、判定と`BeginInvoke`のあいだに残る隙間は`ObjectDisposedException`/`InvalidOperationException`を握って表示だけを諦める形にしました。
鮮度の判定を壁時計(`DateTimeOffset`)の引き算で行っていたのを、単調増加のタイムスタンプに変えました。NTP同期や手動設定で時刻が戻ると経過時間が負になり、機器が切れているのにFreshのまま残ります。逆に進めば届いたばかりの値が即Staleになります。`TimeProvider.GetTimestamp`と`GetElapsedTime`で測り、壁時計の受信時刻は表示専用に分けました。
freshnessの判定で、機器側の時計が付けた`ValueTimestamp`をこちらの時計の`now`から引いていたのを直しました。時計が合っていないと、遅れている機器の値が届いた直後にstaleになり、進んでいると止まった値がいつまでもfreshのままになります。連番が最後に進んだときの受信時刻を持ち、budgetは自分の時計で測った経過時間に当てる形にしました。
要約・理由・詳細の3層パネルと、複数台をまとめて出す画面のワイヤーフレームをテキストで追加しました。状態を1か所に集めて購読するC#の骨格と、届いているかと中身が進んでいるかを分けて判定する例を新設し、用語表とNG文言の書き換え表を加えました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589716)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「外部機器の状態の確認と表示のベストプラクティス - 『接続中』だけで済ませない設計」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589716 https://staging.comcomponent.com/blog/2026/03/20/002-external-device-state-check-display-best-practices/

DOI(最新版)
10.5281/zenodo.21589716
DOI(この版)
10.5281/zenodo.21732735

産業用カメラ、バーコードリーダ、PLC、計測器、プリンタ、シリアル機器、USB 機器。 外部機器とつながる Windows アプリでは、実際の不具合そのもの より先に、画面の状態表示が現実とずれること で事故ることがかなり多いです。

たとえば、こんな状態です。

  • OS からは見えているのに、別プロセスが掴んでいて使えない
  • open はできたのに、原点復帰やウォームアップ、認証が終わっていない
  • 機器はぶら下がっているが、応答はもう止まっている
  • 取得スレッドが死んでいるのに、最後の値だけ画面に残っている
  • 想定外の個体や firmware なのに、単に「接続中」と表示してしまう

ここで本当に知りたいのは、つながっているかどうか だけではありません。 いま何を安全にやってよいか です。

この記事の対象読者と前提

項目 内容
対象読者 外部機器とつながる Windows アプリを設計・実装する方。既存アプリで「画面は接続中なのに動かない」という問い合わせを減らしたい方を想定しています
前提とする知識 何らかの言語でアプリを書けること。特定の SDK やデバイスドライバの知識は前提にしません
前提とする環境 Windows デスクトップアプリを想定しています。ただし、状態の分け方と表示の考え方自体は OS に依存しません
扱わないこと 個別ベンダー SDK の API の使い方、ドライバ側の実装

この記事で使う用語

英語のまま出てくる語を、先に一行ずつ整理しておきます。

用語 一行での意味
PLC Programmable Logic Controller。生産設備の制御に使う産業用コントローラ
firmware 機器に組み込まれたソフトウェア。同じ型番でも個体ごとにバージョンが違うことがあります
heartbeat 生存確認のために定期的にやり取りする、軽い問い合わせや通知
poll / event poll はこちらから定期的に問い合わせる方式、event は相手からの通知を待つ方式
stale 値が古い状態。取得できてはいたが、いま画面に出ている値が新しいとは言えない状態
freshness budget 「この時間を超えたら値を新しいとみなさない」と決めておく上限
flapping 状態が短時間で行ったり来たりすること。接触不良や瞬断で起きます
reconcile 突き合わせて内部状態を実態に合わせ直すこと
interlock 安全のために動作を止める仕組み。開いている間は装置が動きません
PnP Plug and Play。OS が機器の接続と切断を検出して構成する仕組み
RTT round-trip time。問い合わせを出してから応答が返るまでの時間

1. まず結論

外部機器の状態確認と表示で一番効くのは、状態を 1 個の boolean に潰さないこと です。

少なくとも、このあたりは分けて持ちたいところです。

  • 存在: OS から見えているか
  • セッション確立: 自アプリが open / login / initialize 済みか
  • 応答性: heartbeat や status query に返るか
  • 機能準備: 実際の操作をいま受け付けられるか
  • データ鮮度: 画面の値は新しいか
  • 構成一致: 想定した個体、型番、firmware か
  • 監視健全性: そもそも監視処理が生きているか

かなり雑に言うと、こうです。

存在確認は OS 側、使用可否はアプリ側、鮮度判定は画面側で持つ。

この 3 つを混ぜないだけで、状態表示はかなり安定します。

画面側が答えることアプリだけが答えられることOS が答えられることここが外れていると他が全部成立していても使えない止まっているとすべての判定が古くなるデータ鮮度表示している値は新しいか監視健全性監視処理そのものが生きているかセッションopen / login / initialize 済みか応答性軽い問い合わせに期限内で返るか機能準備いま操作を受け付けられるか構成一致想定した個体・型番・firmware か存在対象 interface が見えているか

図1: 状態を1個の boolean に潰さず、誰が答えられる問いなのかで分けて持つ。上の段が成立していても下の段が成立するとは限らない

この記事の知識マップ

外部機器と連携するWindowsアプリでは、存在・セッション確立・応答性・機能準備・データ鮮度・構成一致・監視健全性という7つの状態軸を分けて内部に持つことが柱になります。単一の「接続中」表示に潰すとoperatorが次の一手を判断できなくなるため、UIは要約・理由・詳細の3層と状態プラス理由プラス次の行動という文言で見せます。データ鮮度は壁時計ではなく単調増加のタイムスタンプとfreshness budgetで判定し、機器側のValueTimestampとの時計ずれによる誤判定を避けます。監視ワーカーとUIをstate store経由で分離し、画面破棄中のBeginInvoke呼び出しが監視ワーカーを道連れにしないようControl.Disposedへ購読を紐づけ、再接続はbackoff付きにし、flappingは確定表示の前にならします。

外部機器の状態確認と表示の知識マップ存在・セッション確立・応答性・機能準備・データ鮮度・構成一致・監視健全性という7つの状態軸を分けて持つことで、単一の「接続中」表示がoperatorの誤判断を招く問題をどう防ぐかを示す図原因になり得る前提とする前提とする前提とする前提とする前提とする前提とする前提とする防止する軽減する軽減するより先に行うべきで確認できるで確認できるで確認できる前提とする原因になり得る原因になり得る防止する原因になり得る原因になり得る防止する原因になり得る軽減する防止する原因になり得る軽減する前提とする防止するで確認できる前提とする状態を多軸で持つ設計「接続中」への状態集約operatorが次の一手を判断できない状態存在(状態軸)セッション確立(状態軸)応答性(状態軸)機能準備(状態軸)データ鮮度(状態軸)構成一致(状態軸)監視健全性(状態軸)要約・理由・詳細の3層パネル状態+理由+次の行動という文言構成起動時列挙到着/削除通知heartbeatによるポーリングfreshness budget単調増加タイムスタンプ壁時計の跳躍鮮度判定の誤り機器側ValueTimestampとの時計ずれstale dataをlive値として見せること個体識別の不安定さ個体の取り違えbackoff付き再接続最短ループでの再接続による負荷集中flappingのならし監視ワーカーとUIの分離UIスレッドでの監視処理直接実行画面破棄中のBeginInvoke例外によるレース監視ワーカーの停止(道連れ)Control.Disposedに紐づけた購読解除ぶれにくい個体識別キー

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全31件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. なぜ「接続中」が危ないのか

「接続中」という文言は、1 つの文言で複数の意味を勝手に背負ってしまいます。

実際には、少なくとも次の問いが混ざっています。

  1. OS から対象機器の interface が見えているか
  2. 自アプリがその機器を open / login / initialize できているか
  3. 軽い問い合わせに期限内で返るか
  4. いま要求した操作を安全に実行できるか
  5. 画面に出ている値は新しいか
  6. 想定した個体・型番・firmware か

この 6 つのどれを満たしているかで、「使える」の意味は変わります。

たとえば、次の 4 つは全部違います。

  • 未接続 そもそも OS が対象 interface を見つけていない
  • 接続済み / 確認中 物理的には見えているが、初期化や認証が終わっていない
  • 接続済み / 使用不可 応答はあるが、warming up、busy、interlock、media なしなどで動作できない
  • 値が古い 以前は取得できていたが、画面の値は freshness budget を超えている

これらを全部「接続中」で潰すと、operator は何をすればよいか判断できません。

3. まず分けるべき状態

おすすめは、内部状態は多軸で持ち、UI では必要に応じて要約する ことです。

3.1 内部で分けたい状態軸

何を意味するか 典型的な確認方法 UI で見せたい例
存在 OS から対象 interface が見えているか 起動時列挙、arrival / removal 通知 未接続 / 接続済み
セッション 自アプリが open / login / initialize 済みか handle / SDK 初期化結果 確認中 / 初期化中
応答性 status query や heartbeat に返るか timeout 付き軽量問い合わせ 応答あり / 応答遅延 / 応答なし
機能準備 実際の操作がいま可能か device-specific status 使用可能 / busy / warming up
データ鮮度 表示値が新しいか timestamp / sequence 最新 / 値が古い
構成一致 想定機器と一致しているか model / serial / firmware / profile 対象機器 / 想定外の機器
監視健全性 アプリの監視経路が生きているか worker heartbeat / loop lag 監視中 / 監視停止

ここで大事なのは、機器が悪い状態アプリが観測できていない状態 を分けることです。

3.2 UI は全部を平面的に見せなくてよい

内部で多軸に持つと、画面がうるさくなりそうに見えます。 でも UI は全部を同じ重みで出す必要はありません。

おすすめは 3 層です。

  • 上段に 要約状態
  • その下に 理由
  • 必要なら 詳細パネル

たとえば、

  • 要約: 接続済み / 使用不可
  • 理由: ウォームアップ中 残り約 18 秒
  • 詳細: model serial firmware last heartbeat last frame time

のように分けると、情報量を増やしてもかなり読みやすくなります。

画面の骨格としては、こういう並びです。

+-- 前工程カメラ -------------------------------------------+
|
| [要約]  ! 接続済み / 使用不可
| [理由]    ウォームアップ中 - 残り約 18 秒
|
| [詳細]  v 展開する                       (既定は折りたたみ)
|           model           ACME-CAM-2000
|           serial          A1B2C3
|           firmware        2.4.1
|           last heartbeat  10:23:41.512   (0.5 秒前)
|           last frame      10:23:41.402   (0.6 秒前)
|
+-----------------------------------------------------------+

ポイントは、上から下へ行くほど読む人が減ってよい ことです。 要約は誰でも 1 秒で読む、理由は「なぜ止まっているのか」を知りたい人が読む、詳細は切り分けをする人だけが開く。この前提で並べると、詳細を厚くしても画面はうるさくなりません。

逆に、modelserial を要約と同じ大きさで常時表示すると、いちばん大事な 1 行が埋もれます。

4. 状態確認のベストプラクティス

4.1 起動時列挙と到着 / 削除通知

Windows で外部機器を扱うときの土台は、起動時に既存機器を列挙し、以後は arrival / removal 通知を受ける ことです。

特に押さえておきたいのはこの 3 点です。

  • 通知だけでは既存機器は拾えない
  • runtime communication では setup class より interface class のほうが自然
  • remove 通知と I/O error の見え方が前後することがある

実務ルールとしてはシンプルです。

  1. 起動時に列挙する
  2. 通知を購読する
  3. 通知を受けたら再列挙して内部状態を reconcile する

4.2 「存在」「開ける」「応答する」「使える」を分ける

外部機器の事故は、ここをまとめて扱ったときに増えます。

  • 存在する OS から interface が見えている
  • 開ける 他プロセス競合や権限問題なしに handle / session を持てる
  • 応答する 軽い問い合わせに timeout 内で返る
  • 使える 実際の操作を受け付けられる

この 4 つは同じではありません。

4.3 event と poll を混ぜる

event ベースだけ、poll ベースだけ、のどちらかに寄せ切るより、検出は event、健全性確認は poll が実務では扱いやすいです。

  • arrival / removal は event
  • heartbeat / status query は poll
  • freshness 判定は timestamp / sequence

この分け方にすると、接続検出と実使用可否を切り離しやすくなります。

4.4 監視処理と UI を分離する

UI thread で直接 open / read / status query を回すと、表示の都合と監視処理の都合が簡単に混ざります。

おすすめは、

  • 監視ワーカーが state store を更新
  • UI は state store を購読して描画
  • UI 操作は command として監視層へ渡す

という形です。

これで、監視停止と機器停止を分けて扱いやすくなります。

骨格を C# で書くと、次のくらいの分量です(.NET 8 / C# 12 想定)。書き込むのは監視ワーカーだけ、UI は読んで描くだけ、という一方通行にするのがコツです。

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;

public enum DeviceAvailability
{
    Unknown,        // まだ一度も観測できていない
    Absent,         // OS から interface が見えない
    Initializing,   // open / login / initialize の途中
    Ready,          // 操作を受け付けられる
    Unavailable,    // 応答はあるが busy / warming up などで使えない
    NotResponding,  // heartbeat が返らない
    Mismatched,     // 想定外の個体 / firmware
}

// UI へ渡す不変スナップショット。record にして値等価性で差分を判定する
public sealed record DeviceSnapshot(
    string DeviceKey,                 // serial number などのぶれにくいキー
    string DisplayName,
    DeviceAvailability Availability,
    string Reason,                    // 「ウォームアップ中」などの理由
    DateTimeOffset? LastSuccessAt,    // 最後に観測へ成功した時刻
    long Sequence,                    // 機器側が付ける連番
    string FirmwareVersion);

public sealed class DeviceStateStore
{
    private readonly ConcurrentDictionary<string, DeviceSnapshot> _snapshots = new();

    public event Action<DeviceSnapshot>? Changed;

    // 呼ぶのは監視ワーカーだけ
    public void Publish(DeviceSnapshot snapshot)
    {
        _snapshots.TryGetValue(snapshot.DeviceKey, out var previous);
        _snapshots[snapshot.DeviceKey] = snapshot;

        // 値が変わったときだけ通知する。毎 poll ごとに通知すると UI が無駄に再描画される
        if (previous != snapshot)
        {
            Changed?.Invoke(snapshot);
        }
    }

    public IReadOnlyList<DeviceSnapshot> Current() => _snapshots.Values.ToList();
}

UI 側は、購読して UI スレッドへ戻すところまでを 1 か所に閉じ込めます。

using System;
using System.Windows.Forms;

public sealed class DeviceStatusPresenter
{
    private readonly DeviceStateStore _store;
    private readonly Control _uiContext;   // UI スレッドへ戻すための足場
    private readonly Label _summary;
    private readonly Label _reason;

    public DeviceStatusPresenter(DeviceStateStore store, Control uiContext, Label summary, Label reason)
    {
        _store = store;
        _uiContext = uiContext;
        _summary = summary;
        _reason = reason;
        // 画面の寿命に購読を紐づける。Dispose の呼び忘れがそのまま
        // 「閉じたフォームへ更新を投げ続ける」に化けるのを防ぐ
        _uiContext.Disposed += (_, _) => Dispose();
        _store.Changed += OnChanged;      // 購読を忘れると、更新しても画面が変わらない
    }

    public void Dispose() => _store.Changed -= OnChanged;

    private void OnChanged(DeviceSnapshot snapshot)
    {
        // 監視ワーカーは、画面を閉じている最中も動いている。
        // ハンドルが壊れたあとの BeginInvoke は例外を投げ、その例外は
        // Changed?.Invoke を通って監視ワーカー側へ抜けていく。
        // 「画面を閉じたら監視が止まった」という壊れ方になる
        if (_uiContext.IsDisposed || _uiContext.Disposing || !_uiContext.IsHandleCreated)
        {
            return;
        }

        try
        {
            if (_uiContext.InvokeRequired)
            {
                _uiContext.BeginInvoke(() => Render(snapshot));
                return;
            }

            Render(snapshot);
        }
        catch (ObjectDisposedException)
        {
            // 上の判定を通ったあとに閉じられた。この隙間は原理的に消せないので、
            // 表示を諦めることで受け止める。監視は止めない
        }
        catch (InvalidOperationException)
        {
            // ハンドルが未作成/破棄済み。同上
        }
    }

    private void Render(DeviceSnapshot snapshot)
    {
        _summary.Text = snapshot.Availability switch
        {
            DeviceAvailability.Absent => "未接続",
            DeviceAvailability.Initializing => "接続済み / 確認中",
            DeviceAvailability.Ready => "使用可能",
            DeviceAvailability.Unavailable => "接続済み / 使用不可",
            DeviceAvailability.NotResponding => "応答なし",
            DeviceAvailability.Mismatched => "想定外の機器",
            _ => "確認中",
        };
        _reason.Text = snapshot.Reason;
    }
}

画面を閉じる瞬間は、この形でいちばん壊れやすいところです。Changed?.Invoke(snapshot) は監視ワーカーのスレッドから同期的にハンドラーを呼びます。フォームが閉じられてコントロールのハンドルが破棄されたあとに BeginInvoke を呼ぶと例外になり、その例外は Publish を通って監視ワーカーへ抜けていきます。画面の後片付けが、監視そのものを落とすわけです。しかも症状は「終了時にたまに落ちる」なので、再現条件がつかみにくい。

Dispose で購読を外すだけでは足りません。外している最中にワーカーが Invoke を始めていれば、その呼び出しはもう止められないからです。押さえどころは3つです。

  • 画面の寿命に購読を紐づける。Control.Disposed で自動的に購読を外し、Dispose の呼び忘れが「閉じたフォームへ投げ続ける」に化けないようにします
  • 破棄中・破棄済みのコントロールへは投げない。IsDisposed / Disposing / IsHandleCreated を見て、その場で捨てます
  • それでも残る隙間は例外で受け止める。判定と BeginInvoke のあいだで閉じられる可能性は原理的に消せません。ObjectDisposedExceptionInvalidOperationException をここで握って、表示だけを諦める形にします。握らないと、監視が道連れになります

「閉じるときの表示更新は捨ててよい」と決めておくのが要点です。その1回の描画に価値はありませんが、監視ワーカーが生きていることには価値があります。

4.5 freshness は「届いているか」と「中身が進んでいるか」を分けて判定する

データ鮮度の判定は、受信時刻だけ見ていると足りません。 SDK からの callback は届き続けているのに、値の timestamp や sequence が止まっている、という壊れ方があるからです。

なので、受信の新しさ中身の新しさ を分けます。

using System;

public sealed record Reading(
    long Sequence,                  // 機器側が付ける連番
    DateTimeOffset ValueTimestamp,  // 機器側が値に付けた時刻
    DateTimeOffset ReceivedAt,      // アプリが受け取った時刻。画面に出す用
    long ReceivedTicks);            // 同じ受信の単調増加タイムスタンプ。判定用

public enum Freshness
{
    Fresh,
    Stale,
    Unknown,
}

public static class FreshnessPolicy
{
    // freshness budget: これを超えたら live の顔で見せない
    public static readonly TimeSpan Budget = TimeSpan.FromSeconds(5);

    /// <param name="lastAdvancedTicks">連番が最後に進んだときの、単調増加タイムスタンプ</param>
    /// <param name="nowTicks">判定時点の単調増加タイムスタンプ</param>
    public static Freshness Evaluate(
        Reading? previous, Reading? current,
        long lastAdvancedTicks, long nowTicks, TimeProvider clock)
    {
        if (current is null)
        {
            return Freshness.Unknown;   // 一度も取れていない
        }

        if (clock.GetElapsedTime(current.ReceivedTicks, nowTicks) > Budget)
        {
            return Freshness.Stale;     // そもそも届いていない
        }

        if (previous is null)
        {
            return Freshness.Fresh;     // 初回は比較対象がないので受信時刻だけで判断する
        }

        if (current.Sequence < previous.Sequence)
        {
            // 連番が戻った。機器の再起動、別個体への差し替え、SDK 再初期化を疑う
            return Freshness.Unknown;
        }

        if (current.Sequence == previous.Sequence &&
            clock.GetElapsedTime(lastAdvancedTicks, nowTicks) > Budget)
        {
            // 受信は続いているのに中身が更新されていない
            return Freshness.Stale;
        }

        return Freshness.Fresh;
    }
}

lastAdvancedTicks は、次のようにこちら側の時計で持ちます。

public sealed class FreshnessTracker(TimeProvider clock)
{
    private readonly object _gate = new();
    private Reading? _previous;
    private long _lastAdvancedTicks;

    // 受信のたびに呼ぶ。ReceivedAt と ReceivedTicks は同じ受信の記録
    public Reading Capture(long sequence, DateTimeOffset valueTimestamp) =>
        new(sequence, valueTimestamp, clock.GetLocalNow(), clock.GetTimestamp());

    public Freshness Observe(Reading current)
    {
        lock (_gate)
        {
            if (_previous is null || current.Sequence > _previous.Sequence)
            {
                _lastAdvancedTicks = current.ReceivedTicks;
            }

            var result = FreshnessPolicy.Evaluate(
                _previous, current, _lastAdvancedTicks, clock.GetTimestamp(), clock);
            _previous = current;
            return result;
        }
    }

    // 受信が完全に途絶えると Observe は二度と呼ばれない。
    // タイマーから定期的にこれを呼び、最後に受け取った値の「古さ」を測り直す。
    // 状態は更新しないので、何度呼んでも安全
    public Freshness Reevaluate()
    {
        lock (_gate)
        {
            return FreshnessPolicy.Evaluate(
                _previous, _previous, _lastAdvancedTicks, clock.GetTimestamp(), clock);
        }
    }
}

Observe だけでは、機器が黙ったことを検出できません。Observe が呼ばれるのは受信したときだけで、しかもそのとき渡される Readingたった今作ったものです。ReceivedTicks と現在時刻の差はほぼゼロなので、この経路で Stale になるのは「受信は続いているが連番が進まない」場合だけです。SDK からの callback が完全に止まったとき ── いちばん知りたい壊れ方 ── では Observe が呼ばれず、画面は最後に計算した Fresh を出したまま固まります。

なので、受信とは独立した周期で判定し直す口を用意します。上の Reevaluate がそれで、タイマーから呼びます。周期は budget より短くします(budget が 5 秒なら 1 秒ごと、程度)。同じ長さにすると、最悪で budget の 2 倍近く気づけません。

// System.Threading.Timer。受信が無くても判定は進める。
// RenderFreshness は、4.4 の presenter と同じ経路で画面へ反映する自前のメソッド
_freshnessTimer = new Timer(
    _ => RenderFreshness(_tracker.Reevaluate()),
    null, TimeSpan.Zero, TimeSpan.FromSeconds(1));

ObserveReevaluate が別のスレッドから同時に来るので、FreshnessTracker の内部は lock で守ります。ここを省くと、_previous の差し替えと読み出しが混ざり、たまに1世代古い判定が出るという追いにくい不具合になります。

ここで ValueTimestamp との差を取らないのが要点です。ValueTimestamp は機器側の時計で付いた値で、こちらの時計と合っている保証がありません。両者を引き算すると、機器の時計が遅れているだけで届いたばかりの値が stale になり、逆に進んでいると止まった値がいつまでも fresh のままになります。budget は必ず自分の時計で測った経過時間に当ててください。ValueTimestamp は「機器がいつの値だと言っているか」を画面に出したり、連番と合わせて機器側の停止を疑う材料にしたりする用途に留めます。

そして、その「自分の時計」も DateTimeOffset の引き算では足りません。こちらは壁時計で、NTP同期や手動設定、夏時間の切り替えで飛びます。時刻が戻れば経過時間が負になり、機器が切れているのに Fresh のままになります。逆に進めば、届いたばかりの値がその瞬間に Stale になります。24時間動かす画面ほど踏みます。

なので、budget の判定には単調増加のタイムスタンプを使います。TimeProvider.GetTimestamp()Stopwatch ベースの高精度な値を返し、GetElapsedTime(開始, 終了) で2点間の経過時間が取れます(10章の参考資料を参照。.NET 8 以降)。壁時計の ReceivedAt画面に「10:15:03 受信」と出すためだけに残し、「何秒経ったか」の判定には触らせない、という分担にしておくのが安全です。テスト時計へ差し替えられるのも TimeProvider を挟む利点です。

この形にしておくと、UI 側は Freshness.Stale を受け取った時点で「値の横に age を出す」「操作可能判定から外す」という 5.3 の方針をそのまま適用できます。

UnknownStale と別に持っているのは、まだ分からない古い を混ぜないためです。前者は待てば解決するかもしれませんが、後者は待っても直りません。

4.6 個体識別を安定させる

friendly name や COM3 のような見た目の識別子だけで状態を追うと、個体を取り違えやすくなります。

できれば、

  • serial number
  • logical device id
  • stable device path
  • 機器側の個体 ID

のような ぶれにくいキー を内部で持ったほうが安全です。

5. 表示のベストプラクティス

5.1 一枚で見る判断表

実際の状態 UI の要約 補足表示
interface なし 未接続 ケーブル、電源、USB 接続を確認
interface あり、初期化中 接続済み / 確認中 初期化中、認証中、ウォームアップ中
応答あり、操作条件未達 接続済み / 使用不可 busy、media なし、interlock open
応答あり、値が古い 接続済み / 値が古い 最終更新 12 秒前
応答なし 応答なし 再接続中、通信 timeout
想定外個体 想定外の機器 model / serial / firmware 不一致
監視処理停止 監視異常 監視ワーカー停止、再起動が必要

5.2 文言は「状態 + 理由 + 次の行動」

エラー異常 だけでは、画面としては弱いです。 メッセージは次の 3 要素に寄せたほうが、operator が迷いにくくなります。

  • 状態: 何が起きているか
  • 理由: なぜそう判断したか
  • 次の行動: 何をすればよいか

たとえば、

  • 接続済み / 使用不可 - ウォームアップ中 - 約 18 秒待ってください
  • 応答なし - heartbeat timeout - ケーブルと電源を確認してください
  • 想定外の機器 - Firmware 2.1.0 が必要です - 対象機器を確認してください

のような形です。

逆に、現場でよく見る文言と並べると、何が足りないのかがはっきりします。

よくある NG 文言 足りないもの 書き換え例
エラー 状態も理由も次の行動もない 応答なし - heartbeat timeout - ケーブルと電源を確認してください
接続中 状態が曖昧。結局いま使えるのかが分からない 接続済み / 使用不可 - ウォームアップ中 - 約 18 秒待ってください
デバイスが見つかりません 理由と次の行動がない 未接続 - 対象の interface が列挙されていません - ケーブルと電源を確認してください
0x80070005 が発生しました 人が読める状態と行動がない 使用不可 - ポートを open できません。0x80070005 アクセスが拒否されました - 他のアプリが同じポートを使っていないか確認してください
再試行しています... いつまで、何回、次はどうなるかが分からない 再接続中 3 回目 / 最大 10 回 - 次の試行まで 8 秒 - 手動で再接続もできます
正常 いつの時点の正常かが分からない 使用可能 - 最終更新 0.5 秒前

書き換え例に共通しているのは、operator がその画面だけを見て次の一手を決められるか です。 「サポートに連絡してください」しか書けない状態なら、それは文言ではなく、状態設計のほうが足りていません。

5.3 stale data を隠さない

last known value は役に立ちます。 ただし live value の顔で見せない ほうが安全です。

おすすめは、

  • 値の横に timestamp
  • 値の age 表示
  • stale になったら色やラベルを変える
  • 一定時間を超えたら操作可能判定から外す

です。

5.4 重要度に応じて見せる場所を変える

status bar は便利ですが、見落とされやすいです。 critical な異常を status bar の隅にだけ置くのは避けたいところです。

  • 軽微な状態変化: status bar
  • 作業継続可能な注意: inline notice
  • 操作停止が必要な異常: 主表示領域、ダイアログ、バナー

という使い分けが素直です。

5.5 複数台表示では要約と詳細を分ける

複数台の機器を扱う画面では、全件詳細を常に出すと見づらくなります。

  • 上部に 全体サマリ
  • 下部に 機器ごとの行
  • 選択時に 詳細ペイン

の 3 段にすると、全体把握と個別切り分けを両立しやすいです。

画面の骨格はこうなります。

+-- 装置一覧 -----------------------------------------------+
|
| [全体サマリ]  使用可能 6 / 8      注意 1      異常 1
|
| [機器ごとの行]
|     状態        表示名             理由                最終更新
|     --------    ---------------    ----------------    --------
|     使用可能    前工程カメラ       -                    0.5 秒前
|     使用可能    ラベルプリンタ     -                    1.2 秒前
|   > 使用不可    検査カメラ         ウォームアップ中     0.6 秒前   <- 選択中
|     応答なし    バーコードリーダ   heartbeat timeout   48 秒前
|
| [詳細ペイン]  検査カメラ
|     serial A1B2C3 / firmware 2.4.1 / 残り約 18 秒
|     [ 再接続 ]   [ ログを開く ]
|
+------------------------------------------------------------+

この 3 段構成にすると、全体サマリだけを見る人(今日はラインを流してよいか)、行を見る人(どの機器が止まっているか)、詳細ペインを見る人(何をすれば直るか)を、同じ画面で同時に満たせます。

行の並び順は、異常を上へ持ってくる ほうが扱いやすいです。ただし並び順が毎秒入れ替わると押し間違いが起きるので、並べ替えは flapping をならしたあとの確定状態で行います(6.2 参照)。

6. 再接続と運用のベストプラクティス

6.1 再接続は backoff 付きにする

応答が止まったときの再接続を、最短ループで叩き続けないほうが安全です。

  • device / driver / SDK に負荷をかける
  • ログが洪水になる
  • 一時的な不安定を悪化させる
  • UI が激しく揺れる

からです。

現実的なのは、

  • 初回はすぐ retry
  • だめなら段階的に間隔を伸ばす
  • 上限を設ける
  • 手動 再接続 も用意する

です。

状態と遷移条件を図にすると、backoff と再接続上限がどこに効くのかが見えます。

列挙しても見つからない起動時列挙 / 到着通知到着通知削除通知(どの状態からでも)UnknownAbsentPresentopen / login / initialize初期化と構成確認に成功初期化失敗(再試行の余地がある)想定外の個体・型番・firmwarewarming up / 実行中 / interlock操作を受け付けられるI/O エラーI/O エラー / 応答なしが確定backoff を置く待ち時間が経過再接続の上限に到達手動で再接続手動で再接続(自動では戻らない)構成を直してから手動で再接続DetectedOpeningReadyFaultMismatchBusyReconnectingRetryExhausted

図2: この図が扱うのは接続とセッションの寿命だけで、データ鮮度は入れていない。鮮度は機能準備とは独立した軸(図1)で、Ready でも Busy でも同じように古くなりうるため、状態として混ぜると「実行中に値が途切れ、復帰したら Ready になっていた」のような取り違えが起きる。画面では、この図の状態と 4.5 節の鮮度判定を別々に持って組み合わせる。再試行の上限に達しても Absent(未接続)へは落とさず、自動では戻らない RetryExhausted に留める ── 機器は見えたまま壊れていることがあり、未接続と表示すると復旧操作を誤らせる。構成不一致も再試行では直らないので、自動再接続の輪から外している

6.2 flapping をならす

USB 接触不良やネットワーク瞬断のような場面では、状態が短時間で行き来します。 ここで生イベントをそのまま UI に出すとかなり見づらいです。

なので、

  • 内部ログは生イベントのまま残す
  • UI は短い確認期間を置いてから確定表示する
  • ただし critical 異常はすぐ見せる

という使い分けが扱いやすいです。

6.3 最低限残すべきログ

状態表示の改善は、ログ設計とほぼセットです。

項目
timestamp 2026-03-20T10:23:41.512+09:00
stable device key camera:A1B2C3
表示名 前工程カメラ
旧状態 -> 新状態 Ready -> Stale
理由 heartbeat timeout firmware mismatch
エラーコード HRESULT Win32 SDK code
last success 2026-03-20T10:23:36.011+09:00
age / RTT 5.5s 320ms
retry count 3
app / firmware version App 1.8.2 / FW 2.4.1

特に大事なのは、状態遷移ログ です。

6.4 監視停止と機器停止を混同しない

  • poll loop が例外で死んだ
  • SDK callback が止まった
  • acquisition worker が deadlock した
  • state store 更新だけ止まった

こういうとき、機器は生きていてもアプリは観測できていません。 この状態を 未接続応答なし だけで出すと、機器側の問題に見えてしまいます。

なので、監視経路の健全性 は別軸で持ったほうがよいです。

7. 機器タイプ別の見落としやすい点

7.1 USB / PnP 機器

  • 通知だけでは existing device は拾えない
  • runtime では setup class より interface class が自然
  • composite device は複数 interface を出すことがある
  • remove 通知と I/O error の見え方が前後することがある

7.2 シリアル機器

COMx が見えているだけでは安心できません。

  • ポート自体はあるが、対象機器がぶら下がっていない
  • 別プロセスが open している
  • 応答はもう止まっている
  • read / write が timeout で固まる

シリアルでは、存在応答使用可能 を特に分けたほうが安全です。

7.3 ネットワーク機器

ping が通ることと、アプリが使えることを同一視しないほうがよいです。

  • 名前解決できるか
  • TCP 接続できるか
  • アプリ層 handshake できるか
  • status が ready か
  • 値が fresh か

の段階があります。

7.4 SDK 依存のカメラ / 計測機器

SDK callback が来ていることだけで live と決め打ちしないほうが安全です。

  • callback thread 自体が止まる
  • frame は来るが timestamp が進んでいない
  • image stream は来るが control channel が死んでいる
  • reconnect 後の設定再適用が終わっていない

こうしたことが起きるので、SDK の外から見た健全性も持っておくと安心です。

8. やってはいけないこと

  • 状態を 接続中 / 未接続 / エラー の 3 つに潰す
  • 通知だけで existing device も拾えると思う
  • open 成功をそのまま 使用可能 とみなす
  • last known value を fresh な顔で見せる
  • timestamp を表示しない
  • UI thread で open / read / status query を回す
  • retry を最短ループで回す
  • critical な異常を status bar にだけ出す
  • 未接続監視停止 を混同する
  • friendly name や COM3 だけで個体識別する

9. まとめ

外部機器連携アプリで本当に大事なのは、何を確認したら、どこまで言ってよいか を決めることです。

とくに、この分け方が効きます。

存在している 自アプリが開ける 応答している いまその操作ができる 画面の値が新しい

この 5 つを分ける。

そのうえでの実務指針は、ざっとこんなところです。

  • 起動時は列挙、以後は通知
  • 使用可否は heartbeat と device-specific status で決める
  • 表示値には timestamp と age を持たせる
  • critical な異常は見落とされにくい場所へ出す
  • 監視系の異常を機器異常に見せない

「接続中」と出せることより、その表示が現実とどれだけずれにくいか のほうが実務ではずっと大事です。

10. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

機器の状態を「接続中」と表示するだけではなぜ不十分なのですか?
「接続中」という文言は、OSから見えているか、自アプリがopenできているか、応答が返るか、いま操作できるか、画面の値が新しいか、想定した個体かという複数の問いを1つに潰してしまうためです。たとえば「未接続」「接続済み/確認中」「接続済み/使用不可」「値が古い」は全部違う状態で、これらを全部「接続中」で潰すと、operatorは何をすればよいか判断できません。
外部機器の状態は内部でどう分けて持つべきですか?
存在(OSから見えているか)、セッション確立(open/login/initialize済みか)、応答性(heartbeatに返るか)、機能準備(いま操作を受け付けられるか)、データ鮮度(画面の値が新しいか)、構成一致(想定した個体・firmwareか)、監視健全性(監視処理自体が生きているか)を分けて持ちます。雑に言えば、存在確認はOS側、使用可否はアプリ側、鮮度判定は画面側で持つという分担です。UIは要約・理由・詳細の3層に分けて見せれば読みやすくなります。
機器の検出と健全性確認はイベントとポーリングのどちらでやるべきですか?
どちらかに寄せ切るより、検出はevent、健全性確認はpollという分担が実務では扱いやすいです。具体的には、arrival/removalはイベント通知で受け、heartbeatやstatus queryは定期ポーリングで行い、鮮度判定はtimestampやsequenceで行います。ただし通知だけでは既存機器は拾えないため、起動時に列挙し、以後は通知を購読し、通知を受けたら再列挙して内部状態をreconcileするのが実務ルールです。
応答が止まった機器への再接続はどう実装すべきですか?
最短ループで叩き続けず、backoff付きにします。初回はすぐretryし、だめなら段階的に間隔を伸ばし、上限を設け、手動の「再接続」ボタンも用意する形が現実的です。最短ループで回すと、デバイスやSDKに負荷をかけ、ログが洪水になり、一時的な不安定を悪化させます。また、USB接触不良などで状態が短時間で行き来するflappingには、UI側で短い確認期間を置いてから確定表示する方法が有効です。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る