Transport Availability Signaling¶
When an adapter wraps a fallible transport — SSH, BLE, serial, HTTP — the device
availability topic should reflect whether the transport is currently reachable.
cosalette provides first-class support for this via unavailable_on on
@app.command, @app.telemetry and @app.device, and ctx.mark_unavailable().
Telemetry and device entities are automatic
Since ADR-077, a named @app.telemetry or @app.device entity publishes
availability with no parameter at all: retained "offline" once a poll fails
for good — immediately with the default retry=0 or a non-retryable error,
otherwise once its retries are exhausted — and "online" on the next
successful poll. Use
unavailable_on= to narrow which exceptions count, or unavailable_on=None
to switch it off. @app.command stays opt-in — a command runs on demand, so
a failed command says nothing about whether the device is reachable.
Topic convention
The framework publishes "online" or "offline" to
{app}/{device}/availability (retained, QoS 1). Home Assistant MQTT
integration reads this topic to mark entities as available or unavailable
without requiring custom state payloads.
The Problem Without Framework Support¶
Without framework support, each adapter catches transport exceptions and manually constructs an "unavailable" state payload:
@app.command("display")
async def handle_display(ctx: DeviceContext) -> dict[str, object]:
try:
result = await ssh.read()
return {"available": True, "brightness": result.brightness}
except SSHError:
return {"available": False, "brightness": None} # manual unavailable payload
This pattern is repeated across every adapter with a fallible transport, has no standard MQTT availability topic, and doesn't integrate with Home Assistant's entity availability mechanism.
Static Form — unavailable_on¶
Declare which exception types represent transport failures directly on the decorator:
import cosalette
app = cosalette.App(name="wallpanel", version="1.0.0")
class SSHError(Exception):
"""Raised when the SSH connection to the device fails."""
@app.command("display", unavailable_on=(SSHError, TimeoutError)) # (1)!
async def handle_display(ctx: cosalette.DeviceContext) -> dict[str, object]:
result = await ssh.read() # (2)!
return {"brightness": result.brightness} # (3)!
app.run()
unavailable_ondeclares the exception tuple. Any exception in this tuple that escapes the handler is suppressed — it does not propagate.- If
SSHErrororTimeoutErroris raised here, the framework intercepts it. - On success: the framework publishes the returned dict as device state.
What the framework does when a matching exception is raised:
- Suppresses the exception (does not re-raise).
- Publishes
"offline"towallpanel/display/availability(retained, QoS 1). - Logs a structured error payload to
wallpanel/display/error. - Sets an internal
_is_unavailableflag on the device context.
Non-matching exceptions go to the error topic
Only exceptions in the unavailable_on tuple are suppressed. Any other
exception is caught by the framework, logged, and published to the error
topic — the device availability state is unchanged.
Dynamic Form — ctx.mark_unavailable()¶
For conditional unavailability — where you check reachability before attempting
the operation — call ctx.mark_unavailable() directly from the handler body:
@app.command("sensor")
async def handle_sensor(ctx: cosalette.DeviceContext) -> dict[str, object]:
if not await client.is_reachable(): # (1)!
await ctx.mark_unavailable() # (2)!
return {}
data = await client.read()
return {"value": data.value} # (3)!
- Pre-flight reachability check — no exception needed.
mark_unavailable()publishes"offline"to the availability topic.- Next successful invocation triggers auto-recovery — for every archetype; see Defaults and Recovery by Archetype.
Dynamic Form — ctx.mark_available()¶
ctx.mark_available() is the symmetric counterpart to ctx.mark_unavailable():
it publishes "online" (retained, QoS 1) to the same availability topic and
clears the internal _is_unavailable flag.
@app.telemetry("sensor", interval=30)
async def read_sensor(ctx: cosalette.DeviceContext) -> dict[str, object]:
if not await client.is_reachable():
await ctx.mark_unavailable() # (1)!
return {}
if ctx._is_unavailable:
await ctx.mark_available() # (2)!
data = await client.read()
return {"value": data.value}
- Publishes
"offline", same as the command form. - Explicitly signals a recovery your handler detects itself. Declarative
unavailable_on=failures on telemetry and device handlers otherwise recover automatically at their next successful work boundary.
mark_available() is a no-op when no HealthReporter is injected (e.g. in
unit tests that construct a bare DeviceContext), mirroring
mark_unavailable().
Auto-Recovery¶
@app.command handlers share an auto-recovery mechanism between the static and
dynamic forms. After any successful command handler invocation — where no
unavailable_on exception was raised and no early return without a matching
exception — the framework:
- Checks whether the internal
_is_unavailableflag is set. - If yes: publishes
"online"to the availability topic. - Resets the flag to
False.
No explicit "come back online" call is needed in the handler.
MQTT events for two consecutive calls:
Call 1: SSHError raised
→ wallpanel/display/availability "offline" (retained)
→ wallpanel/display/error {...}
Call 2: succeeds
→ wallpanel/display/state {"brightness": 80}
→ wallpanel/display/availability "online" (retained)
Defaults and Recovery by Archetype¶
| Archetype | Publishes offline by default? | Recovery |
|---|---|---|
@app.telemetry (named) |
Yes — on a failed poll (after any retries) | Automatic on the next successful poll |
@app.device (named) |
Yes — on retry exhaustion | Automatic on the next successful poll |
@app.command |
No — declare unavailable_on= |
Automatic after any successful invocation |
Any root entity (name=None) |
No — declare unavailable_on= |
Automatic once opted in |
@app.telemetry gone stale (no fresh cycle for stale_after) |
Yes — named entities, derived bound | Automatic on the next fresh cycle |
A successful poll is a genuine recovery signal for telemetry, which is why these
archetypes now auto-recover (ADR-077, narrowing ADR-047's command-only scoping).
ctx.mark_available() remains available for recovery your handler detects itself.
@app.telemetry("sensor", interval=30, retry=2)
async def read_sensor(ctx: cosalette.DeviceContext) -> dict[str, object]:
return {"value": (await client.read()).value}
Retries exhausted publishes "offline"; the next successful poll publishes
"online". Without retry= — or for an exception outside retry_on — there is
nothing to exhaust, so the first failed poll publishes "offline". Narrow it when a handler bug should not claim the device is
unreachable:
@app.telemetry("sensor", interval=30, unavailable_on=(BleakError, TimeoutError))
async def read_sensor(ctx: cosalette.DeviceContext) -> dict[str, object]:
payload = await client.read()
return {"value": payload["v"]} # a KeyError here does NOT mark it offline
Tolerating temporary failures
retry= tolerates transient failures within a cycle. To tolerate a period
without fresh data, disable the failure mark with unavailable_on=None and
choose an explicit freshness duration, such as
stale_after=180. The entity goes "offline" after 180 seconds without a
fresh cycle. This is elapsed-time tolerance, not a consecutive-failure count:
handler execution, timeout, retries and backoff add to each post-cycle
interval. There is no separate unavailable_after= count (ADR-077).
Root entities are excluded from the default
A root entity publishes to the flat {app}/availability, so one failed read
would declare the whole app unavailable — in Home Assistant that can take
down every entity the app owns. Root entities therefore opt in explicitly
with unavailable_on=(ExcType, ...).
Why the default is every exception
cosalette has no dependency on bleak, paramiko or pyserial, so it cannot
name BleakError, SSHException or serial.SerialException in a default. A
stdlib-only default such as (OSError, TimeoutError) would silently never fire
for exactly the adapters this feature exists for. Narrowing is therefore the
app author's call — only they can name their adapter's exception types.
Freshness — stale_after¶
Failure-driven availability only fires when a poll fails. It cannot see a
telemetry task that has died, a poll that hangs without a timeout, an init=
that keeps failing, or an error that unavailable_on deliberately ignores. The
reading then sits in Home Assistant as if it were current.
The freshness watchdog closes that gap (ADR-080). A named telemetry entity
with no fresh cycle for its stale_after bound publishes retained "offline",
and the next fresh cycle publishes "online" again. The bound is derived from
the schedule unless you set it; streams opt in with @app.stream(...,
stale_after=...).
@app.telemetry("radon", interval=300, stale_after=1800) # offline after 30 min
async def read_radon(ctx: cosalette.DeviceContext) -> dict[str, float]: ...
The freshness mark is a separate availability source: clearing it never brings
an entity online while a failure mark (unavailable_on, ctx.mark_unavailable())
still holds it offline, and vice versa.
Staleness describes which entities can go stale,
how the bound is derived, the heartbeat and health file signals, and the
exit_after_stale and restart_on_stale responses.
Scope — Device-Level¶
Availability state is device-scoped: all handlers that share the same device
name share one availability state. If a device has multiple
commands (e.g. via sub=), a single failure on any one of them marks the whole
device offline.
Which Form to Use¶
| Situation | Recommended form |
|---|---|
| Specific exception type = transport failure | unavailable_on=(ExcType, ...) |
| Reachability check before attempting I/O | ctx.mark_unavailable() |
| Exception + pre-flight check combined | Both together |
| Telemetry/device, any read failure counts | Nothing — it is the default |
| Never mark this entity offline | unavailable_on=None |
| Never mark this entity stale | stale_after=None |
| Root entity should participate | unavailable_on=(ExcType, ...) |
| Signal recovery your handler detects itself | ctx.mark_available() |
Signal recovery from @app.command outside auto-recovery timing |
ctx.mark_available() (optional — auto-recovery also applies) |
Using Both Together¶
@app.command("display", unavailable_on=(SSHError,))
async def handle_display(ctx: cosalette.DeviceContext) -> dict[str, object]:
if not await ssh.ping():
await ctx.mark_unavailable() # proactive check
return {}
return {"brightness": await ssh.read_brightness()} # SSHError auto-handled
Home Assistant Integration¶
The {app}/{device}/availability topic is the standard MQTT availability topic
expected by Home Assistant's MQTT integration. Configure
it in your HA device configuration:
mqtt:
sensor:
- name: "Display Brightness"
state_topic: "wallpanel/display/state"
value_template: "{{ value_json.brightness }}"
availability_topic: "wallpanel/display/availability"
payload_available: "online"
payload_not_available: "offline"
When the transport is unreachable, HA marks the entity as Unavailable instead of showing a stale value.
Relationship to HealthCheckRunner¶
HealthCheckRunner monitors adapter health on
a polling schedule — it calls health_check() at a configurable interval and
flips availability if the probe fails.
Transport availability signaling fires per command invocation — it reacts to real transport errors as they occur.
Both publish to the same {app}/{device}/availability topic and are fully
complementary:
| Mechanism | Trigger | Best for |
|---|---|---|
HealthCheckRunner |
Scheduled health probe | Detecting silent transport loss |
unavailable_on / ctx.mark_unavailable() |
Command handler failure | Reacting to transport errors on demand |
ctx.mark_available() |
Explicit call in handler body | Signaling a recovery the handler detects itself |
Testing¶
Use AppHarness to assert availability topic messages in integration tests:
import asyncio
import pytest
from cosalette import DeviceContext
from cosalette.testing import AppHarness
class TransportError(Exception):
pass
@pytest.mark.asyncio
async def test_device_goes_offline_on_transport_error():
harness = AppHarness.create(name="myapp")
handler_called = asyncio.Event()
@harness.app.command("sensor", unavailable_on=(TransportError,))
async def handle(ctx: DeviceContext) -> None:
handler_called.set()
raise TransportError("unreachable")
async def simulate() -> None:
await asyncio.sleep(0.05)
await harness.inject_command("sensor", "")
await handler_called.wait()
await asyncio.sleep(0.05)
harness.trigger_shutdown()
asyncio.create_task(simulate())
await asyncio.wait_for(harness.run(), timeout=5.0)
msgs = harness.messages_for("myapp/sensor/availability")
assert "offline" in [m[0] for m in msgs]
Note
Use the full AppHarness.create() + harness.run() lifecycle for availability
assertions — call_command() bypasses the HealthReporter wiring needed to
publish availability topics.
See Testing for the full testing guide.