Best Practices for Checking and Displaying External Device State - Designing Beyond a Single 'Connected'
· Updated: · Go Komura · Windows, External Devices, Device Integration, State Management, UI/UX, Monitoring
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614560)
- First published
Cite this article(DOI: 10.5281/zenodo.21614559)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Best Practices for Checking and Displaying External Device State - Designing Beyond a Single 'Connected'. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614559 https://comcomponent.com/en/blog/2026/03/20/002-external-device-state-check-display-best-practices/
- DOI (latest version)
- 10.5281/zenodo.21614559
- DOI (this version)
- 10.5281/zenodo.22218882
Industrial cameras, barcode readers, PLCs, measurement instruments, printers, serial devices, USB devices. In Windows apps that talk to external devices, the on-screen state display drifting away from reality breaks things far more often than an actual defect itself does, and it breaks them first.
For example, states like these.
- The OS can see the device, but another process holds it and it cannot be used
opensucceeded, but homing, warm-up, or authentication has not finished- The device is still attached, but it has stopped responding
- The acquisition thread has died, yet the last value is still sitting on the screen
- It is an unexpected unit or firmware, but the screen simply says “Connected”
What you really want to know here is not just whether it is connected. It is what can safely be done right now.
flowchart TB
accTitle: What you want to know is whether an operation is safe
accDescr: Diagram showing that what you really want to know in a device-integration app is not only whether the device is connected but what can safely be done right now, and that things break because the on-screen state display drifts from reality before any actual defect does.
a1["Is it connected"] -.-> a2["Only part of what you need to know"]
a3["What can I safely do right now"] --> a4["What you really want to know"]
a4 -.-> a5["A display that drifts from reality breaks things first"]
Figure 1: The point of a state display is not to report connectivity but to tell the operator which operations are safe right now.
Who this article is for, and what it assumes
| Item | Details |
|---|---|
| Intended readers | Anyone designing or implementing Windows apps that talk to external devices. In particular, anyone trying to cut down support calls of the form “the screen says Connected but nothing works” in an existing app |
| Assumed knowledge | Being able to write an application in some language. No knowledge of any particular SDK or device driver is assumed |
| Assumed environment | Windows desktop applications. That said, the way state is split up and the display principles themselves do not depend on the OS |
| Out of scope | How to call a specific vendor SDK, and driver-side implementation |
Terms used in this article
Here is a one-line gloss for each term before we start.
| Term | One-line meaning |
|---|---|
| PLC | Programmable Logic Controller. An industrial controller used to control production equipment |
| firmware | Software embedded in the device. Even units with the same model number can be on different versions |
| heartbeat | A lightweight query or notification exchanged periodically to confirm the peer is alive |
| poll / event | Polling means we query periodically; an event means we wait to be notified by the other side |
| stale | A value that is old. Acquisition worked at some point, but there is no basis for calling the value now on screen current |
| freshness budget | The upper bound you fix in advance: past this much time, a value no longer counts as current |
| flapping | State bouncing back and forth over a short window. Caused by flaky contacts or momentary drops |
| reconcile | Cross-checking and realigning internal state with what is actually there |
| interlock | A safety mechanism that halts motion. The equipment will not move while it is open |
| PnP | Plug and Play. The OS mechanism that detects and configures device connection and removal |
| RTT | Round-trip time. The time from issuing a query to receiving the response |
1. The Conclusion First
The single most effective thing in checking and displaying external device state is not collapsing the state into one boolean.
At minimum, you want to keep these separate.
- Presence: is it visible to the OS?
- Session established: has our app completed open / login / initialize?
- Responsiveness: does it answer heartbeats or status queries?
- Operational readiness: can it accept the actual operation right now?
- Data freshness: are the on-screen values recent?
- Configuration match: is it the expected unit, model, firmware?
- Monitoring health: is the monitoring pipeline itself alive?
Put very bluntly:
Presence belongs to the OS side, usability to the app side, and freshness to the display side.
Just keeping these three unmixed makes the state display considerably more stable.
flowchart TB
subgraph OS["What the OS can answer"]
E["Presence<br/>Is the target interface visible"]
end
subgraph APP["What only the app can answer"]
S["Session<br/>Has open / login / initialize completed"]
R["Responsiveness<br/>Does a lightweight query return in time"]
F["Operational readiness<br/>Can it accept an operation right now"]
C["Configuration match<br/>Is it the expected unit, model, firmware"]
end
subgraph UIL["What the display side answers"]
D["Data freshness<br/>Are the displayed values recent"]
W["Monitoring health<br/>Is the monitoring code itself alive"]
end
E --> S --> R --> F --> D
C -.->|"If this is off, nothing else holding<br/>makes the device usable"| F
W -.->|"If this has stopped,<br/>every judgment goes stale"| D
Figure 2: Do not collapse state into a single boolean; hold it split by who can answer the question. A tier holding does not mean the tier below it holds
Knowledge map for this article
In a Windows app that integrates with external devices, the backbone of the design is to hold seven state axes separately inside the app, namely presence, session establishment, responsiveness, operational readiness, data freshness, configuration match, and monitoring health. Collapsing them into a single Connected indicator leaves the operator unable to decide the next move, so the UI shows them in three layers of summary, reason, and details, with wording that gives the state plus the reason plus the next action. Data freshness is judged with a monotonically increasing timestamp and a freshness budget rather than the wall clock, which avoids wrong judgments caused by clock skew against the ValueTimestamp coming from the device. The monitoring worker and the UI are separated through a state store, the subscription is tied to Control.Disposed so that a BeginInvoke call issued while the window is being torn down does not drag the monitoring worker down with it, reconnection is done with backoff, and flapping is smoothed out before the display commits to a state.
flowchart LR
accTitle: Checking and displaying external device state
accDescr: Diagram showing how holding seven separate state axes of presence, session establishment, responsiveness, operational readiness, data freshness, configuration match, and monitoring health prevents a single Connected indicator from leading the operator to a wrong judgment
multi_axis_device_state_model["Multi-Axis Device State Model"]
connected_label_oversimplification["Collapsing Device State into Connected"]
operator_misjudgment["Unclear Next Action for Operator"]
device_existence_state["Existence (State Axis)"]
device_session_state["Session (State Axis)"]
device_responsiveness_state["Responsiveness (State Axis)"]
device_readiness_state["Readiness (State Axis)"]
data_freshness_state["Freshness (State Axis)"]
device_identity_match["Identity Match (State Axis)"]
monitoring_health_state["Monitoring Health (State Axis)"]
three_tier_status_panel["Three-Tier Status Panel"]
status_message_three_elements["Status, Reason, Next Action Wording"]
startup_enumeration["Device Enumeration at Startup"]
arrival_removal_notification["Device Arrival/Removal Notifications"]
heartbeat_polling["Heartbeat Polling"]
freshness_budget["Freshness Budget"]
monotonic_timestamp["Monotonic Timestamp"]
wall_clock_drift["Wall-Clock Time Jumps"]
freshness_misjudgment["Freshness Misjudgment"]
value_timestamp_clock_skew["Clock Skew Against Device ValueTimestamp"]
stale_data_live_masking["Stale Data Shown as Live"]
device_key_instability["Unstable Device Identification"]
device_misidentification["Device Misidentification"]
reconnect_backoff["Reconnect with Backoff"]
reconnect_storm["Reconnect Storm (Tight Retry Loop)"]
flapping_debounce["Flapping Debounce"]
monitoring_worker_ui_separation["Monitoring Worker / UI Separation"]
ui_monitoring_coupling["Monitoring Work on the UI Thread"]
ui_disposal_race["BeginInvoke Race During Form Disposal"]
monitoring_worker_crash["Monitoring Worker Killed by UI Exception"]
control_lifetime_bound_subscription["Subscription Bound to Control.Disposed"]
stable_device_key["Stable Device Identity Key"]
connected_label_oversimplification -->|"may cause"| operator_misjudgment
multi_axis_device_state_model -->|"requires"| device_existence_state
multi_axis_device_state_model -->|"requires"| device_session_state
multi_axis_device_state_model -->|"requires"| device_responsiveness_state
multi_axis_device_state_model -->|"requires"| device_readiness_state
multi_axis_device_state_model -->|"requires"| data_freshness_state
multi_axis_device_state_model -->|"requires"| device_identity_match
multi_axis_device_state_model -->|"requires"| monitoring_health_state
multi_axis_device_state_model -->|"prevents"| connected_label_oversimplification
three_tier_status_panel -->|"mitigates"| operator_misjudgment
status_message_three_elements -->|"mitigates"| operator_misjudgment
startup_enumeration -->|"should come before"| arrival_removal_notification
device_existence_state -->|"verified by"| arrival_removal_notification
device_responsiveness_state -->|"verified by"| heartbeat_polling
data_freshness_state -->|"verified by"| freshness_budget
freshness_budget -->|"requires"| monotonic_timestamp
wall_clock_drift -->|"may cause"| freshness_misjudgment
value_timestamp_clock_skew -->|"may cause"| freshness_misjudgment
monotonic_timestamp -->|"prevents"| freshness_misjudgment
stale_data_live_masking -->|"may cause"| operator_misjudgment
device_key_instability -->|"may cause"| device_misidentification
reconnect_backoff -->|"prevents"| reconnect_storm
reconnect_storm -.->|"may cause"| operator_misjudgment
flapping_debounce -.->|"mitigates"| operator_misjudgment
monitoring_worker_ui_separation -->|"prevents"| ui_monitoring_coupling
ui_disposal_race -->|"may cause"| monitoring_worker_crash
control_lifetime_bound_subscription -->|"mitigates"| monitoring_worker_crash
device_identity_match -->|"requires"| stable_device_key
stable_device_key -->|"prevents"| device_misidentification
monitoring_health_state -->|"verified by"| monitoring_worker_ui_separation
monitoring_worker_ui_separation -->|"requires"| control_lifetime_bound_subscription
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (31 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. Why “Connected” Is Dangerous
The word “Connected” silently takes on multiple meanings in a single label.
In reality, at least these questions are mixed together.
- Can the OS see the device’s interface?
- Has our app managed to open / login / initialize the device?
- Does it answer a lightweight query within the deadline?
- Can the operation we just requested be executed safely right now?
- Are the values on screen recent?
- Is it the expected unit, model, and firmware?
Which of these six are satisfied changes what “usable” means.
For instance, the following four are all different.
- Not connected The OS has not found the target interface in the first place
- Connected / Verifying Physically visible, but initialization or authentication has not finished
- Connected / Not available Responding, but unable to operate due to warming up, busy, an interlock, no media, and so on
- Stale value Acquisition used to work, but the on-screen value has exceeded the freshness budget
Collapse all of these into “Connected” and the operator cannot decide what to do.
flowchart TB
accTitle: The questions Connected has to carry
accDescr: Diagram showing that the single label Connected silently carries several questions - whether the OS can see the device, whether it answers, whether it can be operated, whether the values are recent, whether it is the expected unit - and that flattening all of them leaves the operator unable to decide what to do.
b1["Is it visible to the OS"] --> b4["Everything collapses into Connected"]
b2["Does it answer, can it be operated"] --> b4
b3["Are the values recent, is it the expected unit"] --> b4
b4 --> b5["The operator cannot decide the next move"]
Figure 3: The single word Connected silently carries several questions with different answers.
3. The States to Separate First
Our recommendation: keep internal state multi-axis, and summarize for the UI as needed.
3.1 The state axes to keep internally
| Axis | What it means | Typical check | Example for the UI |
|---|---|---|---|
| Presence | Is the target interface visible to the OS? | Enumeration at startup, arrival / removal notifications | Not connected / Connected |
| Session | Has our app completed open / login / initialize? | Handle / SDK initialization result | Verifying / Initializing |
| Responsiveness | Does it answer status queries or heartbeats? | Lightweight query with timeout | Responding / Slow response / No response |
| Operational readiness | Is the actual operation possible right now? | Device-specific status | Available / Busy / Warming up |
| Data freshness | Are the displayed values recent? | Timestamp / sequence | Current / Stale value |
| Configuration match | Does it match the expected device? | Model / serial / firmware / profile | Target device / Unexpected device |
| Monitoring health | Is the app’s monitoring path alive? | Worker heartbeat / loop lag | Monitoring / Monitoring stopped |
What matters here is separating a device in a bad state from a state the app cannot observe.
3.2 The UI does not need to show everything flat
Holding multi-axis state internally sounds like it would make the screen noisy. But the UI does not need to present everything with equal weight.
Our recommendation is three layers.
- A summary state at the top
- The reason beneath it
- A details panel when needed
For example:
- Summary:
Connected / Not available - Reason:
Warming upAbout 18 seconds remaining - Details:
modelserialfirmwarelast heartbeatlast frame time
Split like this, you can add a lot of information while keeping it quite readable.
As a screen skeleton, the layout runs like this.
+-- Upstream process camera -------------------------------------------+
|
| [Summary] ! Connected / Not available
| [Reason] Warming up - about 18 seconds remaining
|
| [Details] v Expand (collapsed by default)
| model ACME-CAM-2000
| serial A1B2C3
| firmware 2.4.1
| last heartbeat 10:23:41.512 (0.5 s ago)
| last frame 10:23:41.402 (0.6 s ago)
|
+----------------------------------------------------------------------+
The point is that it is fine for fewer people to read each layer as you go down. Anyone reads the summary in one second; the reason is read by whoever wants to know why it stopped; the details are opened only by whoever is doing triage. Lay it out on that assumption and the screen stays quiet no matter how much you add to the details.
Conversely, showing model and serial permanently at the same size as the summary buries the one line that matters most.
flowchart TB
accTitle: The three layers of summary, reason, and details
accDescr: Diagram showing that the UI is split into a summary state on top, the reason beneath it, and a details panel when needed, and that laying it out on the assumption that fewer people read each layer down keeps it readable even as information is added.
c1["Summary: anyone reads it in one second"] --> c2["Reason: whoever wants to know why it stopped"]
c2 --> c3["Details: only whoever is doing triage opens it"]
c3 -.-> c4["Heavy details still leave the screen quiet"]
Figure 4: Split into three layers on the assumption that each layer down is read by fewer people.
4. Best Practices for State Checking
4.1 Enumeration at startup, plus arrival / removal notifications
The foundation for handling external devices on Windows is to enumerate existing devices at startup and receive arrival / removal notifications afterward.
Three points worth nailing down in particular:
- Notifications alone do not pick up devices that already exist
- For runtime communication, interface classes are more natural than setup classes
- Removal notifications and I/O errors can appear out of order relative to each other
The practical rule is simple.
- Enumerate at startup
- Subscribe to notifications
- On a notification, re-enumerate and reconcile internal state
flowchart TB
accTitle: The practical rule for enumeration and notifications
accDescr: Diagram showing the practical rule that because notifications alone do not pick up devices that already exist, you enumerate at startup, subscribe to arrival and removal notifications afterward, and re-enumerate to reconcile internal state whenever a notification arrives.
d1["1. Enumerate at startup"] --> d2["2. Subscribe to notifications"]
d2 --> d3["3. On a notification, re-enumerate and reconcile"]
d1 -.-> d4["Notifications alone miss devices that already exist"]
Figure 5: Track presence with all three together: enumeration, notifications, and reconciliation.
4.2 Separate “present,” “openable,” “responding,” and “usable”
Trouble with external devices increases when these are handled as one.
- Present The interface is visible to the OS
- Openable You can hold a handle / session without contention from another process or permission problems
- Responding It answers a lightweight query within the timeout
- Usable It can accept the actual operation
These four are not the same.
flowchart TB
accTitle: Present, openable, responding, and usable are different
accDescr: Diagram showing that present meaning the interface is visible to the OS, openable meaning a session can be held without contention or permission problems, responding meaning a lightweight query returns within the timeout, and usable meaning the actual operation is accepted are four different things.
e1["Present"] --> e2["Openable"]
e2 --> e3["Responding"]
e3 --> e4["Usable"]
e1 -.-> e5["These four are not the same"]
Figure 6: There are four verification stages from present to usable.
4.3 Mix events and polling
Rather than committing entirely to event-based or entirely to poll-based, in practice the workable split is events for detection, polling for health checks.
- Arrival / removal: events
- Heartbeats / status queries: polling
- Freshness judgments: timestamps / sequences
This division makes it easy to decouple connection detection from actual usability.
flowchart TB
accTitle: Events for detection, polling for health
accDescr: Diagram showing that instead of committing entirely to one style, arrival and removal are detected through events, health is checked by periodic polling of heartbeats and status queries, and freshness is judged from timestamps or sequence numbers.
f1["Detecting arrival and removal"] --> f2["event"]
f3["heartbeat and status query"] --> f4["poll"]
f5["Freshness judgment"] --> f6["timestamp / sequence"]
Figure 7: Do not make events and polling compete; use each for the job it suits.
4.4 Separate the monitoring pipeline from the UI
If you run open / read / status queries directly on the UI thread, display concerns and monitoring concerns mix all too easily.
Our recommendation:
- A monitoring worker updates a state store
- The UI subscribes to the state store and renders
- UI actions are passed to the monitoring layer as commands
This also makes it easier to treat “monitoring stopped” and “device stopped” separately.
flowchart TB
accTitle: Separate monitoring and the UI with a state store
accDescr: Diagram showing that a monitoring worker updates the state store, the UI subscribes to the state store and renders, and UI actions are passed to the monitoring layer as commands, a one-way arrangement that makes it easier to treat monitoring stopped and device stopped separately.
g1["Monitoring worker"] -->|"updates"| g2["state store"]
g2 -->|"subscribes and renders"| g3["UI"]
g3 -.->|"actions passed as commands"| g1
g2 -.-> g4["One-way: only the worker writes"]
Figure 8: Put a state store between monitoring and the UI, and keep the flow one-way.
Written in C#, the skeleton comes out about this size (assuming .NET 8 / C# 12). The trick is to keep it one-way: only the monitoring worker writes, and the UI only reads and renders.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
public enum DeviceAvailability
{
Unknown, // never observed even once
Absent, // the interface is not visible to the OS
Initializing, // in the middle of open / login / initialize
Ready, // able to accept operations
Unavailable, // responding, but busy / warming up and so on make it unusable
NotResponding, // heartbeats do not come back
Mismatched, // unexpected unit / firmware
}
// Immutable snapshot handed to the UI. Make it a record so value equality detects changes
public sealed record DeviceSnapshot(
string DeviceKey, // a key that does not drift, such as a serial number
string DisplayName,
DeviceAvailability Availability,
string Reason, // the reason, such as warming up
DateTimeOffset? LastSuccessAt, // when observation last succeeded
long Sequence, // sequence number assigned by the device
string FirmwareVersion);
public sealed class DeviceStateStore
{
private readonly ConcurrentDictionary<string, DeviceSnapshot> _snapshots = new();
public event Action<DeviceSnapshot>? Changed;
// Only the monitoring worker calls this
public void Publish(DeviceSnapshot snapshot)
{
_snapshots.TryGetValue(snapshot.DeviceKey, out var previous);
_snapshots[snapshot.DeviceKey] = snapshot;
// Notify only when the value changed. Notifying on every poll repaints the UI for nothing
if (previous != snapshot)
{
Changed?.Invoke(snapshot);
}
}
public IReadOnlyList<DeviceSnapshot> Current() => _snapshots.Values.ToList();
}
On the UI side, confine subscription and the hop back to the UI thread to a single place.
using System;
using System.Windows.Forms;
public sealed class DeviceStatusPresenter
{
private readonly DeviceStateStore _store;
private readonly Control _uiContext; // foothold for getting back onto the UI thread
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;
// Tie the subscription to the lifetime of the window, so a forgotten Dispose
// does not turn into posting updates to a closed form forever
_uiContext.Disposed += (_, _) => Dispose();
_store.Changed += OnChanged; // forget to subscribe and updates never reach the screen
}
public void Dispose() => _store.Changed -= OnChanged;
private void OnChanged(DeviceSnapshot snapshot)
{
// The monitoring worker keeps running while the window is closing.
// BeginInvoke after the handle is gone throws, and that exception
// escapes through Changed?.Invoke into the monitoring worker.
// The failure looks like: closing the window stopped monitoring
if (_uiContext.IsDisposed || _uiContext.Disposing || !_uiContext.IsHandleCreated)
{
return;
}
try
{
if (_uiContext.InvokeRequired)
{
_uiContext.BeginInvoke(() => Render(snapshot));
return;
}
Render(snapshot);
}
catch (ObjectDisposedException)
{
// Closed after passing the checks above. This gap cannot be eliminated in principle,
// so absorb it by giving up the render. Monitoring keeps running
}
catch (InvalidOperationException)
{
// Handle not created or already destroyed. Same as above
}
}
private void Render(DeviceSnapshot snapshot)
{
_summary.Text = snapshot.Availability switch
{
DeviceAvailability.Absent => "Not connected",
DeviceAvailability.Initializing => "Connected / Verifying",
DeviceAvailability.Ready => "Available",
DeviceAvailability.Unavailable => "Connected / Not available",
DeviceAvailability.NotResponding => "No response",
DeviceAvailability.Mismatched => "Unexpected device",
_ => "Verifying",
};
_reason.Text = snapshot.Reason;
}
}
The moment the window closes is the most fragile point in this arrangement. Changed?.Invoke(snapshot) calls the handler synchronously, on the monitoring worker’s thread. Call BeginInvoke after the form has closed and the control’s handle has been destroyed and it throws, and that exception escapes through Publish into the monitoring worker. Cleaning up the screen takes down monitoring itself. Worse, the symptom is “it crashes occasionally on exit,” so the repro conditions are hard to pin down.
Unsubscribing in Dispose is not enough. If the worker has already started an Invoke while you are unsubscribing, that call can no longer be stopped. There are three things to get right.
- Tie the subscription to the lifetime of the window. Unsubscribe automatically on
Control.Disposedso that a forgottenDisposedoes not turn into posting to a closed form forever - Do not post to a control that is being disposed or already disposed. Check
IsDisposed/Disposing/IsHandleCreatedand drop the update on the spot - Absorb the gap that remains with a catch. Nothing can rule out the window closing between the check and
BeginInvoke. CatchObjectDisposedExceptionandInvalidOperationExceptionhere and give up only the display. Without that catch, monitoring goes down with it
The key is deciding up front that display updates during shutdown are expendable. That single render is worth nothing, whereas the monitoring worker staying alive is worth a great deal.
flowchart TB
accTitle: Taking the monitor down with the window
accDescr: Diagram showing that Changed invokes handlers synchronously on the monitoring thread, so an exception from BeginInvoke after the handle is destroyed escapes through Publish into the monitoring worker and stops monitoring, which is why the subscription is tied to the lifetime of the window and the remaining gap is caught so that only the display is given up.
h1["The window closes"] --> h2["BeginInvoke after disposal throws"]
h2 --> h3["The exception escapes into the monitoring worker"]
h3 --> h4["Closing the window stopped monitoring"]
h4 -.->|"to prevent this"| h5["Tie the subscription to the window lifetime"]
h5 --> h6["Catch the remaining gap and give up only the display"]
Figure 9: Give up the one render at closing time, and keep the monitoring worker alive instead.
4.5 Judge freshness by separating “is it arriving” from “are the contents advancing”
Looking only at the reception time is not enough for a data freshness judgment. There is a failure mode where the callbacks from the SDK keep arriving while the value’s timestamp or sequence has stopped moving.
So separate how recent the reception is from how recent the contents are.
using System;
public sealed record Reading(
long Sequence, // sequence number assigned by the device
DateTimeOffset ValueTimestamp, // time the device stamped on the value
DateTimeOffset ReceivedAt, // time the app received it. For display
long ReceivedTicks); // monotonic timestamp of that same reception. For judgments
public enum Freshness
{
Fresh,
Stale,
Unknown,
}
public static class FreshnessPolicy
{
// freshness budget: past this, do not show it with the face of a live value
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(5);
/// <param name="lastAdvancedTicks">Monotonic timestamp of the last time the sequence advanced</param>
/// <param name="nowTicks">Monotonic timestamp at the moment of the judgment</param>
public static Freshness Evaluate(
Reading? previous, Reading? current,
long lastAdvancedTicks, long nowTicks, TimeProvider clock)
{
if (current is null)
{
return Freshness.Unknown; // nothing has ever been acquired
}
if (clock.GetElapsedTime(current.ReceivedTicks, nowTicks) > Budget)
{
return Freshness.Stale; // nothing is arriving at all
}
if (previous is null)
{
return Freshness.Fresh; // nothing to compare against on the first one, so judge on reception time alone
}
if (current.Sequence < previous.Sequence)
{
// The sequence went backward. Suspect a device restart, a swap to another unit, or SDK re-initialization
return Freshness.Unknown;
}
if (current.Sequence == previous.Sequence &&
clock.GetElapsedTime(lastAdvancedTicks, nowTicks) > Budget)
{
// Data keeps arriving, but the contents are not being updated
return Freshness.Stale;
}
return Freshness.Fresh;
}
}
Hold lastAdvancedTicks on our own clock, like this.
public sealed class FreshnessTracker(TimeProvider clock)
{
private readonly object _gate = new();
private Reading? _previous;
private long _lastAdvancedTicks;
// Call on every reception. ReceivedAt and ReceivedTicks record the same reception
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;
}
}
// If reception stops completely, Observe is never called again.
// Call this periodically from a timer to re-measure the age of the last value received.
// It does not update state, so it is safe to call any number of times
public Freshness Reevaluate()
{
lock (_gate)
{
return FreshnessPolicy.Evaluate(
_previous, _previous, _lastAdvancedTicks, clock.GetTimestamp(), clock);
}
}
}
Observe alone cannot detect that the device has gone quiet. Observe runs only on reception, and the Reading handed to it at that moment was created a fraction of a second earlier. The gap between ReceivedTicks and now is essentially zero, so the only way this path yields Stale is the case where data keeps arriving but the sequence stops advancing. When the callbacks from the SDK stop entirely - the failure you most want to catch - Observe is never called, and the screen freezes showing the last Fresh it computed.
So provide an entry point that re-judges on a period independent of reception. That is Reevaluate above, called from a timer. Make the period shorter than the budget (roughly once a second for a 5-second budget). Match the two lengths and, in the worst case, you stay unaware for close to twice the budget.
flowchart TB
accTitle: Observe alone cannot detect silence
accDescr: Diagram showing that Observe runs only on reception, so when the callbacks from the SDK stop entirely Observe is never called and the screen freezes on the last Fresh it computed, which is why Reevaluate is called on a period independent of reception to re-measure the age of the last value.
i1["Callbacks stop entirely"] --> i2["Observe is never called again"]
i2 --> i3["The screen freezes on the last Fresh"]
i3 -.->|"the fix"| i4["Call Reevaluate from a timer"]
i4 --> i5["Make the period shorter than the budget"]
Figure 10: The failure you most want to catch, total silence, is invisible to a reception-driven check.
// System.Threading.Timer. Keep the judgment moving even when nothing is received.
// RenderFreshness is your own method that pushes to the screen through the same path as the presenter in 4.4
_freshnessTimer = new Timer(
_ => RenderFreshness(_tracker.Reevaluate()),
null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
Observe and Reevaluate arrive from different threads at the same time, so the internals of FreshnessTracker are guarded by a lock. Skip that and the swap of _previous interleaves with reads of it, producing a hard-to-chase bug where the judgment is occasionally one generation behind.
The key here is not to take the difference against ValueTimestamp. ValueTimestamp was stamped by the device’s clock, and nothing guarantees it agrees with ours. Subtract one from the other and a device whose clock merely runs behind makes a value that just arrived look stale, while a device running ahead keeps a frozen value looking fresh indefinitely. Always apply the budget to elapsed time measured on our own clock. Keep ValueTimestamp for showing what point in time the device claims the value is from, and for combining with the sequence number as evidence that the device side has stalled.
And even “our own clock” is not enough if it means subtracting DateTimeOffset values. That is a wall clock, and it jumps on NTP sync, manual changes, and daylight saving transitions. If the time moves backward, elapsed time goes negative and a disconnected device stays Fresh. If it moves forward, a value that just arrived turns Stale on the spot. The screens that run around the clock are the ones that hit this.
So use a monotonic timestamp for the budget check. TimeProvider.GetTimestamp() returns a high-resolution, Stopwatch-based value, and GetElapsedTime(start, end) gives the elapsed time between two of them (see the references in section 10; .NET 8 and later). Keep the wall-clock ReceivedAt only for printing “received at 10:15:03” on screen, and keep it away from any “how many seconds have passed” judgment. Being able to swap in a test clock is another benefit of going through TimeProvider.
flowchart TB
accTitle: Judge freshness with a monotonic clock
accDescr: Diagram showing that the wall clock jumps on NTP sync or manual changes, so subtracting wall clock readings can leave a disconnected device looking Fresh or mark a value that just arrived as Stale, which is why the budget is checked with a monotonic timestamp and the wall clock reception time is kept for display only.
j1["The wall clock jumps on NTP or manual changes"] --> j2["Elapsed time goes negative or far too large"]
j2 --> j3["The freshness judgment is wrong"]
j3 -.->|"which is why"| j4["Judge with a monotonic timestamp"]
j4 --> j5["Keep the wall clock reception time for display only"]
Figure 11: Never using the wall clock for how many seconds have passed is the foundation of freshness judgment.
With this in place, the moment the UI receives Freshness.Stale it can apply the policy from 5.3 directly: show the age next to the value, and drop it from operability decisions.
Unknown is kept separate from Stale so that not known yet and old do not get mixed. The first may resolve if you wait; the second will not.
4.6 Stabilize unit identification
If you track state only by cosmetic identifiers such as friendly names or COM3, it becomes easy to confuse one unit with another.
Where possible, it is safer to internally hold keys that do not drift, such as:
- a serial number
- a logical device id
- a stable device path
- the device’s own unit ID
5. Best Practices for Display
5.1 The one-page decision table
| Actual state | UI summary | Supplementary display |
|---|---|---|
| No interface | Not connected | Check the cable, power, and USB connection |
| Interface present, initializing | Connected / Verifying | Initializing, authenticating, warming up |
| Responding, operating conditions not met | Connected / Not available | Busy, no media, interlock open |
| Responding, value stale | Connected / Stale value | Last updated 12 seconds ago |
| No response | No response | Reconnecting, communication timeout |
| Unexpected unit | Unexpected device | Model / serial / firmware mismatch |
| Monitoring pipeline stopped | Monitoring fault | Monitoring worker stopped; restart required |
5.2 Word messages as “state + reason + next action”
Error or Fault alone is weak as a display.
Messages built on these three elements leave the operator far less lost.
- State: what is happening
- Reason: why the app reached that judgment
- Next action: what to do
For example:
Connected / Not available - Warming up - Please wait about 18 secondsNo response - Heartbeat timeout - Check the cable and powerUnexpected device - Firmware 2.1.0 required - Check the target device
Lining these up against the wording you actually see in the field makes it obvious what is missing.
| Common bad wording | What is missing | Rewrite |
|---|---|---|
Error |
No state, no reason, no next action | No response - Heartbeat timeout - Check the cable and power |
Connected |
The state is vague. It still does not say whether the device is usable right now | Connected / Not available - Warming up - Please wait about 18 seconds |
Device not found |
No reason and no next action | Not connected - The target interface is not enumerated - Check the cable and power |
0x80070005 occurred |
No human-readable state and no action | Not available - Cannot open the port. 0x80070005 access denied - Check whether another app is using the same port |
Retrying... |
No sense of until when, how many times, or what happens next | Reconnecting, attempt 3 of 10 - Next attempt in 8 seconds - You can also reconnect manually |
Normal |
No indication of as of when it was normal | Available - Last updated 0.5 seconds ago |
What the rewrites have in common is this: can the operator decide the next move from that screen alone? If the best you can write is “contact support,” the shortfall is not in the wording; it is in the state design.
flowchart TB
accTitle: Word messages as state plus reason plus next action
accDescr: Diagram showing that Error or Fault alone is weak and that leaning on three elements - the state saying what is happening, the reason saying why the app judged so, and the next action saying what to do - lets the operator decide the next move from that screen alone.
k1["State: what is happening"] --> k4["Wording that leaves the operator in no doubt"]
k2["Reason: why the app judged so"] --> k4
k3["Next action: what to do"] --> k4
k4 -.-> k5["Can the next move be decided from this screen alone"]
Figure 12: You can grade a message by whether all three elements are present.
5.3 Do not hide stale data
A last known value is useful. But it is safer not to present it with the face of a live value.
Our recommendations:
- A timestamp next to the value
- An age display for the value
- Change the color or label when it goes stale
- Exclude it from operability decisions after a set time
5.4 Vary where things are shown by severity
The status bar is convenient, but easy to miss. You want to avoid putting a critical fault only in the corner of the status bar.
- Minor state changes: status bar
- Cautions that allow work to continue: inline notice
- Faults requiring operations to stop: the main display area, a dialog, a banner
That division of labor is the straightforward one.
5.5 For multi-device views, separate summary from detail
In screens that handle many devices, showing full detail for every unit at all times becomes unreadable.
- An overall summary at the top
- A row per device below
- A details pane on selection
This three-tier layout balances at-a-glance awareness with per-unit triage.
The screen skeleton looks like this.
+-- Device list ---------------------------------------------------------------------+
|
| [Overall summary] Available 6 / 8 Caution 1 Fault 1
|
| [Row per device]
| State Display name Reason Last update
| ----------- ---------------- ---------------- -----------
| Available Upstream camera - 0.5 s ago
| Available Label printer - 1.2 s ago
| > Not available Inspection camera Warming up 0.6 s ago <- selected
| No response Barcode reader heartbeat timeout 48 s ago
|
| [Details pane] Inspection camera
| serial A1B2C3 / firmware 2.4.1 / about 18 seconds remaining
| [ Reconnect ] [ Open log ]
|
+------------------------------------------------------------------------------------+
This three-tier structure serves three readers at once on the same screen: whoever only looks at the overall summary (can we run the line today), whoever reads the rows (which device has stopped), and whoever opens the details pane (what will fix it).
For row ordering, bringing faults to the top works better. But if the order reshuffles every second people click the wrong row, so sort on the committed state after flapping has been smoothed out (see 6.2).
flowchart TB
accTitle: Three tiers serve three kinds of reader
accDescr: Diagram showing that a multi-device screen split into an overall summary at the top, a row per device below, and a details pane on selection serves, on one screen, the person asking whether the line can run today, the person asking which device stopped, and the person asking what will fix it.
m1["Overall summary"] -.-> m2["Can the line run today"]
m1 --> m3["Row per device"]
m3 -.-> m4["Which device has stopped"]
m3 --> m5["Details pane"]
m5 -.-> m6["What will fix it"]
Figure 13: Build a multi-device screen as three tiers for three different readers.
6. Best Practices for Reconnection and Operations
6.1 Reconnect with backoff
When a device stops responding, it is safer not to hammer reconnection in the tightest possible loop, because it
- loads the device / driver / SDK
- floods the logs
- worsens transient instability
- makes the UI jitter violently
The realistic approach:
- Retry immediately the first time
- If that fails, lengthen the interval in stages
- Set an upper bound
- Provide a manual
Reconnectas well
Drawing the states and their transition conditions shows where backoff and the reconnect limit actually bite.
stateDiagram-v2
[*] --> Unknown
Unknown --> Absent: Not found even after enumeration
Unknown --> Present: Startup enumeration / arrival notification
Absent --> Present: Arrival notification
Present --> Absent: Removal notification (from any state)
state Present {
[*] --> Detected
Detected --> Opening: open / login / initialize
Opening --> Ready: Initialization and configuration check succeeded
Opening --> Fault: Initialization failed (retry is still an option)
Opening --> Mismatch: Unexpected unit, model, or firmware
Ready --> Busy: warming up / running / interlock
Busy --> Ready: Able to accept operations
Busy --> Fault: I/O error
Ready --> Fault: I/O error / no response confirmed
Fault --> Reconnecting: Apply backoff
Reconnecting --> Opening: Wait elapsed
Reconnecting --> RetryExhausted: Reconnect limit reached
Fault --> Opening: Manual reconnect
RetryExhausted --> Opening: Manual reconnect (no automatic return)
Mismatch --> Opening: Fix the configuration, then reconnect manually
}
Figure 14: This diagram covers only the lifetime of the connection and the session; data freshness is not in it. Freshness is an axis independent of operational readiness (Figure 1), and a value can go stale the same way in Ready as in Busy, so mixing it in as a state invites confusion such as “the values cut out mid-run and it was Ready again by the time it came back.” On screen, hold the states in this diagram and the freshness judgment from section 4.5 separately and combine them. Reaching the retry limit does not drop the device to Absent (not connected); it stays in RetryExhausted, from which it never returns automatically - a device can be broken while remaining visible, and reporting it as not connected sends the operator into the wrong recovery step. A configuration mismatch is not fixed by retrying either, so it is kept out of the automatic reconnect loop
6.2 Smooth out flapping
In situations like flaky USB contacts or momentary network drops, the state bounces back and forth in a short time. Feeding raw events straight to the UI here is quite hard to read.
So the workable split is:
- Keep raw events as-is in the internal log
- Have the UI wait through a short confirmation window before committing the display
- But show critical faults immediately
flowchart TB
accTitle: Smooth out flapping before committing the display
accDescr: Diagram showing that when a flaky contact or momentary drop makes the state bounce back and forth, raw events are kept as-is in the internal log, the UI waits through a short confirmation window before committing a display, and only critical faults are shown immediately.
n1["State bounces back and forth"] --> n2["Raw events go to the log unchanged"]
n1 --> n3["The UI waits, then commits a display"]
n3 -.-> n4["Only critical faults are shown at once"]
Figure 15: Raw events go to the log; the UI gets the smoothed, committed state.
6.3 The minimum log fields to keep
Improving state display goes hand in hand with log design.
| Item | Example |
|---|---|
| timestamp | 2026-03-20T10:23:41.512+09:00 |
| stable device key | camera:A1B2C3 |
| display name | Upstream process camera |
| old state -> new state | Ready -> Stale |
| reason | heartbeat timeout firmware mismatch |
| error code | 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 |
The most important of all is the state transition log.
6.4 Do not conflate monitoring stopped with device stopped
- The poll loop died on an exception
- SDK callbacks stopped
- The acquisition worker deadlocked
- Only the state store updates stopped
In these cases, the device may be alive but the app cannot observe it.
If you display this state as just Not connected or No response, it looks like a device-side problem.
So the health of the monitoring path deserves its own axis.
flowchart TB
accTitle: Do not make a stopped monitor look like a stopped device
accDescr: Diagram showing that when the poll loop dies or callbacks stop the device may be alive while the app simply cannot observe it, and reporting that as not connected or no response makes it look like a device-side problem, which is why monitoring health is held on its own axis.
p1["The poll loop dies on an exception"] --> p3["The device is alive but unobserved"]
p2["Callbacks or the worker stop"] --> p3
p3 --> p4["Reporting Not connected blames the device"]
p4 -.->|"which is why"| p5["Hold monitoring health on its own axis"]
Figure 16: Do not let “cannot observe” be mistaken for “the device is broken.”
7. Easily Missed Points by Device Type
7.1 USB / PnP devices
- Notifications alone do not pick up existing devices
- At runtime, interface classes are more natural than setup classes
- Composite devices may expose multiple interfaces
- Removal notifications and I/O errors can appear out of order
7.2 Serial devices
Seeing COMx is no grounds for comfort.
- The port exists, but the target device is not attached to it
- Another process has it open
- It has already stopped responding
- Reads / writes hang on timeouts
For serial, it is especially safe to separate present, responding, and available.
7.3 Network devices
It is better not to equate a successful ping with the app being able to use the device.
There are stages:
- Can the name be resolved?
- Can a TCP connection be made?
- Can the application-layer handshake complete?
- Is the status ready?
- Are the values fresh?
flowchart TB
accTitle: The stages between ping succeeding and the device being usable
accDescr: Diagram showing that for network devices there are stages - name resolution, TCP connection, application-layer handshake, status ready, and values fresh - and that a successful ping must not be equated with the device being usable.
q1["Name resolves"] --> q2["TCP connects"]
q2 --> q3["Application-layer handshake completes"]
q3 --> q4["Status is ready"]
q4 --> q5["Values are fresh"]
q1 -.-> q6["ping succeeding does not mean usable"]
Figure 17: Getting to usable on a network device takes five stages.
7.4 SDK-dependent cameras / measurement instruments
It is safer not to declare a device live merely because SDK callbacks are arriving.
- The callback thread itself stalls
- Frames arrive but the timestamps are not advancing
- The image stream arrives but the control channel is dead
- Settings have not been reapplied after a reconnect
These things happen, so holding a view of health from outside the SDK gives extra assurance.
8. Things Not to Do
- Collapse state into the three of
Connected / Not connected / Error - Assume notifications alone also pick up existing devices
- Treat a successful
openasAvailableoutright - Present a last known value with a fresh face
- Omit the timestamp from the display
- Run open / read / status queries on the UI thread
- Run retries in the tightest possible loop
- Show critical faults only in the status bar
- Conflate
Not connectedwithMonitoring stopped - Identify units only by friendly name or
COM3
9. Summary
What really matters in device-integration apps is deciding what you have to verify before you are entitled to say how much.
This breakdown in particular pays off:
It is present Our app can open it It is responding That operation is possible right now The on-screen values are recent
Separate these five.
On top of that, the practical guidelines roughly are:
- Enumerate at startup, notifications afterward
- Decide usability from heartbeats and device-specific status
- Give displayed values a timestamp and an age
- Put critical faults where they are hard to miss
- Do not let monitoring faults masquerade as device faults
In practice, how rarely the display drifts from reality matters far more than being able to say “Connected.”
10. References
- Microsoft Learn, TimeProvider Class (
GetTimestampreturns a high-resolution,Stopwatch-based value, andGetElapsedTime(Int64, Int64)gives the elapsed time between two of them) - Microsoft Learn, CM_Register_Notification
- Microsoft Learn, Registering for Notification of Device Interface Arrival and Device Removal
- Microsoft Learn, Registering for Device Notification
- Microsoft Learn, Comparison of setup classes and interface classes
- Microsoft Learn, Device Information Sets
- Microsoft Learn, SetupDiEnumDeviceInterfaces
- Microsoft Learn, Communications functions
- Microsoft Learn, ClearCommError
- Microsoft Learn, COMMTIMEOUTS structure
- Microsoft Learn, WaitCommEvent
- Microsoft Learn, Monitoring Communications Events
- Microsoft Learn, Status Bars (Design basics)
- Microsoft Learn, UX checklist for desktop applications
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Windows Time Synchronization (w32time) and Business Systems — Solving "The Log Timestamps Don't Match" from the Mechanism Up
Why do timestamps drift between a device and a PC? This article explains it from the mechanics of the Windows Time service (w32time): the...
Windows App Outsourcing and Custom Software Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or custom software development, here is how to sort out existing software modification, devi...
The Depths of Windows Virtualization (Part 3) — Virtual Machines That Boot in Seconds: Why WSL2, Windows Sandbox, and Containers Are So Light
Why do WSL2 and Windows Sandbox start in seconds and feel so light? This article explains the mechanisms, from dynamic base images and di...
The Depths of Windows Virtualization (Part 2) — Memory Even the Kernel Cannot See: How VBS, HVCI, and Credential Guard Work
On a clean install to compatible hardware, VBS is enabled by default and uses the hypervisor and SLAT to create isolation stronger than t...
The Depths of Windows Virtualization (Part 1) — Where Is Your Windows Actually Running? The Hypervisor and Partitions
When you enable Hyper-V, the host Windows itself runs on top of the hypervisor as the root partition. This article explains the foundatio...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
In external device integration apps, not just the communication layer but the consistency between state management and UI display directly affects operational quality, so sorting it out at design time keeps failures down.
Technical Consulting & Design Review
State designs where 'Connected' is not enough become much easier to judge when reviewed along separate axes: detection, responsiveness, availability, data freshness, and reconnection.
Frequently Asked Questions
Common questions about the topic of this article.
- Why is it not enough to display a device as 'Connected'?
- Because the label 'Connected' flattens several separate questions into one: whether the OS can see the device, whether the app has managed to open it, whether it answers at all, whether it can be operated right now, whether the on-screen values are recent, and whether it is the expected unit. 'Not connected', 'Connected / Verifying', 'Connected / Not available', and 'Stale value' are all different states, and once every one of them is flattened into 'Connected', the operator has no way to decide what to do.
- How should external device state be tracked internally?
- Track it as separate axes: presence (is it visible to the OS), session established (has open / login / initialize completed), responsiveness (does it answer a heartbeat), operational readiness (can it accept an operation right now), data freshness (are the on-screen values recent), configuration match (is it the expected unit and firmware), and monitoring health (is the monitoring code itself alive). Put bluntly, presence belongs to the OS side, usability to the app side, and freshness to the display side. Showing the result in three UI layers - summary, reason, details - keeps it readable.
- Should device detection and health checking use events or polling?
- Rather than committing entirely to one or the other, the split that works in practice is events for detection and polling for health checks. Concretely: take arrival and removal as event notifications, run heartbeats and status queries as periodic polls, and judge freshness from timestamps or sequence numbers. Notifications alone will not pick up devices that are already present, so the practical rule is to enumerate at startup, subscribe to notifications afterward, and re-enumerate on every notification to reconcile internal state.
- How should reconnection to a device that stopped responding be implemented?
- Do not hammer it in the tightest possible loop; add backoff. The realistic shape is to retry immediately the first time, lengthen the interval in stages when that fails, set an upper bound, and also provide a manual 'Reconnect' button. A tight loop loads the device and the SDK, floods the logs, and makes transient instability worse. For flapping - state bouncing back and forth over a short window because of a flaky USB contact, for example - it works well to have the UI wait through a short confirmation period before committing to a display.