Quickstart¶
In this tutorial you'll build a minimal cosalette app from scratch — a simulated
temperature sensor that publishes telemetry to MQTT every 5 seconds. Along the way
you'll learn the core concepts: the App orchestrator, device decorators,
configuration, and testing.
No hardware required
This quickstart uses a simulated sensor so you can follow along on any machine. The same patterns apply when you swap in real hardware (I²C, BLE, GPIO, etc.) — that's the hexagonal architecture at work (see ADR-006).
Prerequisites¶
Before you begin, make sure you have:
- Python 3.14+ — cosalette uses modern Python features (PEP 695 type parameter syntax, PEP 544 Protocols).
- An MQTT broker — Mosquitto is the easiest to set up
locally. On Debian/Ubuntu:
sudo apt install mosquitto. On macOS:brew install mosquitto. - cosalette installed — see the installation instructions.
uv recommended
The examples below use uv for project management.
You can substitute pip if you prefer, but uv handles virtual environments
and lockfiles automatically.
1. Create the Project¶
Set up a minimal project structure:
Your project should look like this:
weather2mqtt/
├── pyproject.toml
├── src/
│ └── weather2mqtt/
│ ├── __init__.py
│ └── app.py # ← you'll create this
└── tests/
└── test_app.py # ← you'll create this
2. Define Your App¶
Create src/weather2mqtt/app.py:
import cosalette # (1)!
app = cosalette.App( # (2)!
name="weather2mqtt",
version="0.1.0",
)
- The
cosalettepackage re-exports everything you need from a single namespace. No need to import from private modules. Appis the composition root — the central orchestrator that collects device registrations, lifespan logic, and adapter mappings, then runs the full async lifecycle. This follows the Inversion of Control principle (see ADR-001).
The name parameter sets the MQTT topic prefix (weather2mqtt/...) and the
log service name. The version is exposed via the --version CLI flag.
3. Add a Telemetry Device¶
cosalette supports three device archetypes (see ADR-010):
- Telemetry (
@app.telemetry()) — periodic read-and-publish, unidirectional. - Command (
@app.command()) — declarative per-command handler (recommended for most command use cases). - Command & Control (
@app.device()) — bidirectional coroutine with full lifecycle control.
For a sensor, the telemetry pattern is the right fit. Add this to app.py:
import random
import cosalette
app = cosalette.App(
name="weather2mqtt",
version="0.1.0",
)
@app.telemetry("sensor", interval=5.0) # (1)!
async def sensor() -> dict[str, object]: # (2)!
"""Simulate a temperature and humidity sensor."""
temperature = 20.0 + random.uniform(-2.0, 2.0) # (3)!
humidity = 55.0 + random.uniform(-5.0, 5.0)
return { # (4)!
"temperature": round(temperature, 1),
"humidity": round(humidity, 1),
}
-
@app.telemetryregisters a periodic polling device. The framework calls your function everyintervalseconds (here, every 5s) and publishes the result automatically. Theintervalkeyword argument is required.Configurable intervals
When using a custom settings class,
app.settingsis available at decoration time. This lets you driveinterval=from configuration:app = cosalette.App( name="weather2mqtt", version="0.1.0", settings_class=Weather2MqttSettings, ) @app.telemetry("sensor", interval=app.settings.poll_interval)See the Configuration guide for details.
- Handlers declare only the parameters they need. This simple sensor needs no
infrastructure access, so it takes zero arguments. If you need settings, adapters,
or the shutdown event, add a
ctx: cosalette.DeviceContextparameter and the framework injects it automatically. - We're simulating readings here. In a real app, you'd call your hardware
adapter — e.g.,
sensor.read()for I²C, orawait ble_client.read()for BLE. - Returning a
dictis the telemetry contract. The framework callsctx.publish_state(result)for you, serialising the dict as JSON to the topicweather2mqtt/sensor/statewithretain=Trueandqos=1.
- Handlers declare only the parameters they need. This simple sensor needs no
infrastructure access, so it takes zero arguments. If you need settings, adapters,
or the shutdown event, add a
Telemetry vs. Command vs. Device
With @app.telemetry(), you return a dict and the framework publishes it.
With @app.command(), you declare a handler for a single command device —
the framework subscribes and dispatches automatically. Handlers only declare
the parameters they need (topic, payload, or both).
With @app.device(), you manage your own loop and call ctx.publish_state()
yourself — giving you full control over timing, state transitions, and command
handling.
4. Add an Entry Point¶
Add the entry point to app.py:
import random
import cosalette
app = cosalette.App(
name="weather2mqtt",
version="0.1.0",
)
@app.telemetry("sensor", interval=5.0)
async def sensor() -> dict[str, object]:
"""Simulate a temperature and humidity sensor."""
temperature = 20.0 + random.uniform(-2.0, 2.0)
humidity = 55.0 + random.uniform(-5.0, 5.0)
return {
"temperature": round(temperature, 1),
"humidity": round(humidity, 1),
}
if __name__ == "__main__":
app.run() # (1)!
app.run()builds a Typer-based CLI, parses command-line arguments, loads settings, and starts the async lifecycle. You get--dry-run,--version,--log-level,--log-format, and--env-fileflags for free (see ADR-005).
5. Run the App¶
Broker required
Make sure your MQTT broker is running before starting the app. If Mosquitto is
installed locally, sudo systemctl start mosquitto (or brew services start
mosquitto on macOS).
Start the app:
You should see structured JSON log output as the app connects to MQTT and starts publishing:
{"timestamp": "2026-02-17T10:00:00+00:00", "level": "INFO", "logger": "cosalette._mqtt", "message": "MQTT connected to localhost:1883", "service": "weather2mqtt"}
In another terminal, subscribe to see the telemetry:
Every 5 seconds you'll see messages like:
weather2mqtt/sensor/state {"temperature": 19.3, "humidity": 52.7}
weather2mqtt/sensor/availability online
The framework automatically publishes per-device availability on
{prefix}/{device}/availability when devices start. On unexpected disconnection,
the broker publishes an LWT (Last Will & Testament) "offline" message on
{prefix}/status — that's the health reporting system
(see ADR-012).
Press Ctrl+C to shut down gracefully. The framework handles SIGINT/SIGTERM, cancels device tasks, publishes an offline status, and disconnects cleanly.
6. Add Configuration¶
cosalette uses pydantic-settings
for configuration. The base Settings class provides MQTT and logging configuration
out of the box with the __ (double underscore) nested delimiter.
Environment Variables¶
Override any setting via environment variables:
.env Files¶
Create a .env file in your project root:
MQTT__HOST=192.168.1.100
MQTT__PORT=1883
MQTT__USERNAME=iot
MQTT__PASSWORD=secret
LOGGING__LEVEL=DEBUG
LOGGING__FORMAT=text
The app loads .env automatically on startup (configurable with --env-file).
Configuration hierarchy
Settings are resolved in this order (highest priority first):
- CLI flags (
--log-level,--log-format) - Environment variables
.envfile- Model defaults (e.g.,
mqtt.host="localhost")
This follows the twelve-factor app methodology. See ADR-003 for the full rationale.
7. Explore the CLI¶
app.run() gives you a full CLI for free. Try these flags:
# Show version
uv run python src/weather2mqtt/app.py --version
# Override log level for debugging
uv run python src/weather2mqtt/app.py --log-level DEBUG
# Use human-readable log format instead of JSON
uv run python src/weather2mqtt/app.py --log-format text
# Point to a different .env file
uv run python src/weather2mqtt/app.py --env-file production.env
Dry-Run Mode¶
The --dry-run flag is designed for testing without real hardware. When you register
adapters with a dry_run variant, the framework automatically swaps implementations:
For the simple weather app (which has no custom adapters), --dry-run sets the flag
but doesn't change behaviour. It becomes powerful when you add hardware adapters
with mock alternatives — for example, an I²C adapter with a simulated dry-run variant.
Dry-run and adapters
Dry-run mode swaps registered adapters to their dry-run variants, not the MQTT client itself. This means your app still connects to MQTT and publishes — but the hardware interactions use safe stand-ins. Register dry-run adapters like this:
app.adapter(
SensorPort,
RealSensorAdapter,
dry_run=SimulatedSensorAdapter, # used when --dry-run is passed
)
See Hardware Adapters for the full pattern.
8. Add a Test¶
Testing is a first-class concern in cosalette (see
ADR-007). The cosalette.testing module
provides pre-built test doubles so you never need a real MQTT broker or hardware in
your test suite.
Create tests/test_app.py:
import asyncio
import contextlib
import json
import pytest
from cosalette.testing import AppHarness # (1)!
@pytest.fixture
def harness() -> AppHarness:
"""Create a test harness with fresh doubles."""
return AppHarness.create(name="weather2mqtt") # (2)!
@pytest.mark.asyncio
async def test_sensor_publishes_telemetry(harness: AppHarness) -> None:
"""Verify the sensor device publishes state to MQTT."""
from weather2mqtt.app import sensor # (3)!
# Register the telemetry function on the harness's app
harness.app.telemetry("sensor", interval=0.01)(sensor) # (4)!
# Track when a publish arrives, then trigger shutdown
publish_done = asyncio.Event()
original_publish = harness.mqtt.publish
async def _tracking_publish( # (5)!
topic: str,
payload: str,
*,
retain: bool = False,
qos: int = 1,
) -> None:
await original_publish(topic, payload, retain=retain, qos=qos)
if topic == "weather2mqtt/sensor/state":
publish_done.set()
harness.mqtt.publish = _tracking_publish # type: ignore[assignment]
async def _shutdown_after_first_publish() -> None: # (6)!
await publish_done.wait()
harness.trigger_shutdown()
_task = asyncio.create_task(_shutdown_after_first_publish())
try:
await asyncio.wait_for(harness.run(), timeout=5.0) # (7)!
finally:
_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _task
# Assert: the sensor published to the correct topic
messages = harness.mqtt.get_messages_for("weather2mqtt/sensor/state") # (8)!
assert len(messages) >= 1
payload_str, retain, qos = messages[0]
assert retain is True
assert qos == 1
payload = json.loads(payload_str)
assert "temperature" in payload # (9)!
assert "humidity" in payload
AppHarnessis the integration-test entry point. It bundles a freshApp,MockMqttClient,FakeClock, andSettings— no real I/O anywhere.create()is a classmethod that wires everything together. You can pass**settings_overridesto customise configuration.- Import your device function. Since it's decorated with
@app.telemetryat module level, you need to re-register it on the harness's app. - The
@app.telemetrydecorator returns the original function unchanged, so you can re-register the same function on a differentAppinstance. We use a tiny interval (0.01s) so the test runs fast. - We wrap
publishwith a tracking function that sets an event when the telemetry message arrives. This is the idiomatic pattern for waiting on async MQTT publishes in tests. - A background task waits for the first publish, then triggers graceful shutdown.
This runs concurrently with the app lifecycle via
asyncio.create_task. asyncio.wait_foradds a safety timeout — if something goes wrong, the test fails after 5 seconds instead of hanging forever. Thetry/finallyensures the background task is always cancelled, avoiding "Task was destroyed but it is still pending" warnings.get_messages_for()returns(payload, retain, qos)tuples for a given topic. This is the primary assertion point for MQTT behaviour.- We check for key presence rather than exact values since the simulated sensor
uses
random.uniform. In a real app with deterministic hardware mocks, you'd assert exact values.
Run the test:
Test utilities at a glance
| Class / Function | Purpose |
|---|---|
AppHarness.create() |
Full integration harness with test doubles |
MockMqttClient |
In-memory MQTT double — records publishes and subscriptions |
FakeClock |
Deterministic clock — manually set ._time |
NullMqttClient |
Silent no-op MQTT adapter |
make_settings() |
Create Settings without .env files or environment leakage |
Import everything from cosalette.testing:
What's Next?¶
You've built a working telemetry daemon with configuration, a CLI, and tests. Here's where to go from here:
-
Architecture
Understand the composition-root pattern, the async lifecycle, and how cosalette orchestrates your devices.
-
Device Archetypes
Learn about telemetry vs. command & control devices, and when to use each pattern.
-
Configuration Guide
Subclass
Settings, add custom fields, wire environment variables and.envfiles. -
Testing Guide
Advanced testing patterns —
AppHarness, adapter mocking, command simulation, and error injection. -
Publish Strategies
Control when telemetry is published — time-based, change-based, or threshold-based filtering.