---
icon: material/rocket-launch
---

# Build a Complete IoT Bridge

This capstone guide combines everything from the previous guides into a complete,
production-ready `gas2mqtt` application. You'll build a gas meter bridge daemon with
telemetry polling, valve commands, hardware abstraction, lifecycle management, custom
error types, and a full test suite.

!!! note "Prerequisites"

    This guide assumes you've completed the
    [Quickstart](../getting-started/quickstart.md) and are familiar with the individual
    guides:

    - [Telemetry Device](telemetry-device.md)
    - [Command & Control Device](command-device.md)
    - [Configuration](configuration.md)
    - [Hardware Adapters](adapters.md)
    - [Lifespan](lifespan.md)
    - [Testing](testing.md)
    - [Custom Error Types](error-types.md)

## 1. Project Structure

```text
gas2mqtt/
├── pyproject.toml
├── .env
├── src/
│   └── gas2mqtt/
│       ├── __init__.py
│       ├── app.py            # App assembly + devices
│       ├── settings.py       # Custom settings
│       ├── ports.py          # Protocol ports
│       ├── adapters.py       # Hardware adapters
│       └── errors.py         # Domain exceptions
└── tests/
    ├── conftest.py
    ├── unit/
    │   ├── test_counter.py
    │   ├── test_valve.py
    │   └── test_errors.py
    └── integration/
        └── test_app.py
```

Each file has a single responsibility — this keeps the codebase navigable and
testable as the project grows.

## 2. Custom Settings

Define app-specific configuration fields, inheriting MQTT and logging settings from
the framework:

```python title="src/gas2mqtt/settings.py"
"""Configuration for gas2mqtt."""

from __future__ import annotations

from pydantic import Field, SecretStr, field_validator
from pydantic_settings import SettingsConfigDict

import cosalette


class Gas2MqttSettings(cosalette.Settings):
    """Gas meter bridge configuration.

    Environment variables use the ``GAS2MQTT_`` prefix.
    Nested models use ``__`` as delimiter:
    ``GAS2MQTT_MQTT__HOST=broker.local``.
    """

    model_config = SettingsConfigDict(
        env_prefix="GAS2MQTT_",
        env_nested_delimiter="__",
        env_file=".env",
        env_file_encoding="utf-8",
    )

    # Hardware
    serial_port: str = Field(
        default="/dev/ttyUSB0",
        description="Serial port for the gas meter sensor.",
    )
    baud_rate: int = Field(
        default=9600,
        description="Serial baud rate.",
    )

    # Polling
    counter_interval: int = Field(
        default=60,
        ge=1,
        description="Impulse counter polling interval in seconds.",
    )

    @field_validator("serial_port")
    @classmethod
    def serial_port_must_be_device(cls, v: str) -> str:
        """Validate that serial_port looks like a device path."""
        if not v.startswith("/dev/"):
            msg = f"serial_port must be a /dev/ path, got: {v!r}"
            raise ValueError(msg)
        return v
```

!!! info "Why subclass `Settings`?"

    The base `cosalette.Settings` includes `mqtt` and `logging` sub-models. By
    subclassing, your app inherits broker connection and logging config for free —
    you only add the fields unique to gas2mqtt. See [Configuration](configuration.md)
    for the full guide.

## 3. Protocol Port

Define the hardware abstraction as a PEP 544 Protocol:

```python title="src/gas2mqtt/ports.py"
"""Protocol ports for gas2mqtt hardware abstraction."""

from __future__ import annotations

from typing import Protocol, runtime_checkable


@runtime_checkable
class GasMeterPort(Protocol):
    """Hardware abstraction for gas meter sensors.

    Implementations provide impulse counting and temperature
    reading over the protocol boundary.
    """

    def connect(self, port: str, baud_rate: int = 9600) -> None:
        """Open connection to the sensor."""
        ...

    def read_impulses(self) -> int:
        """Read the current impulse count."""
        ...

    def read_temperature(self) -> float:
        """Read the sensor's temperature reading in Celsius."""
        ...

    def close(self) -> None:
        """Close the hardware connection."""
        ...
```

The port defines _what_ your code needs. The adapters in the next section define
_how_ to provide it.

## 4. Real Adapter

The production adapter communicates over a serial port:

```python title="src/gas2mqtt/adapters.py"
"""Hardware adapter implementations for gas2mqtt."""

from __future__ import annotations


class SerialGasMeter:
    """Real gas meter adapter communicating over a serial port.

    Uses ``pyserial`` for UART communication. Imported lazily by
    the framework via ``"gas2mqtt.adapters:SerialGasMeter"`` so
    that ``pyserial`` doesn't need to be installed on dev machines.
    """

    def __init__(self) -> None:
        self._conn = None

    def connect(self, port: str, baud_rate: int = 9600) -> None:
        """Open the serial connection."""
        import serial  # (1)!

        self._conn = serial.Serial(port, baud_rate, timeout=5)

    def read_impulses(self) -> int:
        """Read impulse count from the meter."""
        assert self._conn is not None, "Call connect() first"
        self._conn.write(b"READ_IMPULSES\n")
        response = self._conn.readline().decode().strip()
        return int(response)

    def read_temperature(self) -> float:
        """Read temperature from the meter's built-in sensor."""
        assert self._conn is not None, "Call connect() first"
        self._conn.write(b"READ_TEMP\n")
        response = self._conn.readline().decode().strip()
        return float(response)

    def close(self) -> None:
        """Close the serial connection."""
        if self._conn is not None:
            self._conn.close()
            self._conn = None
```

1. `pyserial` is imported inside the method, not at module level. This is the
   hexagonal lazy-import pattern ([ADR-006](../adr/ADR-006-hexagonal-architecture.md))
   — the module can be imported on machines without `pyserial` installed.

## 5. Mock Adapter

A fake implementation for `--dry-run` mode and testing:

```python title="src/gas2mqtt/adapters.py (continued)"
class FakeGasMeter:
    """Mock gas meter for dry-run mode and testing.

    Returns incrementing impulse counts and a fixed temperature.
    Requires no hardware or external libraries.
    """

    def __init__(self) -> None:
        self._impulses = 0
        self._connected = False

    def connect(self, port: str, baud_rate: int = 9600) -> None:
        self._connected = True

    def read_impulses(self) -> int:
        self._impulses += 1
        return self._impulses

    def read_temperature(self) -> float:
        return 21.5

    def close(self) -> None:
        self._connected = False
```

!!! tip "Fake vs Stub vs Mock"

    `FakeGasMeter` is a _fake_ — it has working logic (incrementing counter) but no
    real hardware dependency. Fakes are great for dry-run mode because they produce
    realistic-looking data. In unit tests, you might use simpler stubs with fixed
    return values.

## 6. Telemetry Device

The impulse counter polls the gas meter sensor at a fixed interval:

```python title="src/gas2mqtt/app.py (counter device)"
@app.telemetry("counter", interval=60)
async def counter(ctx: cosalette.DeviceContext) -> dict[str, object]:
    """Read gas meter impulses and temperature.

    The framework calls this every 60 seconds. The returned dict
    is published as JSON to ``gas2mqtt/counter/state``.
    """
    meter = ctx.adapter(GasMeterPort)

    impulses = meter.read_impulses()
    temperature = meter.read_temperature()

    if impulses < 0:
        raise InvalidReadingError(f"Negative impulse count: {impulses}")

    return {
        "impulses": impulses,
        "temperature_celsius": temperature,
        "unit": "m³",
    }
```

This is the return-dict contract in action: your function reads the sensor and
returns data. The framework handles JSON serialisation, MQTT publication, error
catching, and the timing loop.

## 7. Command Device

The valve device receives open/close commands via MQTT and publishes state:

```python title="src/gas2mqtt/app.py (valve device)"
@app.command("valve")
async def handle_valve(
    payload: str, ctx: cosalette.DeviceContext
) -> dict[str, object]:
    """Control the gas valve via MQTT commands.

    Subscribes to ``gas2mqtt/valve/set`` for inbound commands.
    Returns state dict — the framework publishes to
    ``gas2mqtt/valve/state``.
    """
    match payload:
        case "open":
            state = "open"
        case "close":
            state = "closed"
        case "toggle":
            raise ValueError("Toggle requires tracking previous state")
        case _:
            raise ValueError(
                f"Unknown command: {payload!r}. Valid: open, close"
            )

    return {"state": state}
```

Compare this to the telemetry device above: `@app.command` handlers are even
simpler — they receive a command and return state. No main loop, no closures,
no `nonlocal`.

## 8. Lifespan

Initialise the serial connection at startup, close it at shutdown using the
lifespan context manager:

```python title="src/gas2mqtt/app.py (lifespan)"
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan(ctx: cosalette.AppContext) -> AsyncIterator[None]:
    """Open serial connection before devices start, close after."""
    meter = ctx.adapter(GasMeterPort)
    settings = ctx.settings
    assert isinstance(settings, Gas2MqttSettings)
    meter.connect(settings.serial_port, settings.baud_rate)
    yield  # (1)!
    meter.close()
```

1. Everything before `yield` runs before devices start. Everything after `yield`
   runs after devices stop. The `yield` is where the application's device phase
   executes.

!!! warning "AppContext — limited API"

    The lifespan receives `AppContext`, which has only `.settings` and `.adapter()`.
    There is NO `publish_state()`, `sleep()`, or `on_command` — those are
    `DeviceContext`-only. See [Lifespan](lifespan.md) for details.

## 9. Custom Error Types

Define domain exceptions and the error type map:

```python title="src/gas2mqtt/errors.py"
"""Domain exceptions for gas2mqtt."""


class SensorTimeoutError(Exception):
    """Gas meter sensor didn't respond within the timeout period."""


class InvalidReadingError(Exception):
    """Sensor returned a reading outside valid physical bounds."""


class ConnectionLostError(Exception):
    """Serial connection to the gas meter was lost."""


error_type_map: dict[type[Exception], str] = {
    SensorTimeoutError: "sensor_timeout",
    InvalidReadingError: "invalid_reading",
    ConnectionLostError: "connection_lost",
}
```

When `counter` raises `InvalidReadingError("Negative impulse count: -3")`, the
framework's error isolation catches it and publishes:

```json title="gas2mqtt/counter/error"
{
    "error_type": "error",
    "message": "Negative impulse count: -3",
    "device": "counter",
    "timestamp": "2026-02-18T10:30:00+00:00",
    "details": {}
}
```

The framework uses the generic `"error"` type for all auto-caught exceptions.
To get domain-specific types like `"invalid_reading"`, use `build_error_payload()`
manually — see [Custom Error Types](error-types.md)
for the full guide.

## 10. App Assembly

Wire everything together in `app.py`:

```python title="src/gas2mqtt/app.py"
"""gas2mqtt — Gas meter IoT-to-MQTT bridge.

A complete cosalette application with telemetry polling,
command control, hardware abstraction, and lifespan management.
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import cosalette

from gas2mqtt.adapters import FakeGasMeter
from gas2mqtt.errors import InvalidReadingError
from gas2mqtt.ports import GasMeterPort
from gas2mqtt.settings import Gas2MqttSettings


# --- Lifespan ---


@asynccontextmanager
async def lifespan(ctx: cosalette.AppContext) -> AsyncIterator[None]:
    """Open serial connection before devices start, close after."""
    meter = ctx.adapter(GasMeterPort)
    settings = ctx.settings
    assert isinstance(settings, Gas2MqttSettings)
    meter.connect(settings.serial_port, settings.baud_rate)
    yield
    meter.close()


# --- App construction ---

app = cosalette.App(
    name="gas2mqtt",
    version="1.0.0",
    settings_class=Gas2MqttSettings,
    lifespan=lifespan,
)

# --- Adapter registration ---

app.adapter(
    GasMeterPort,
    "gas2mqtt.adapters:SerialGasMeter",  # (1)!
    dry_run=FakeGasMeter,  # (2)!
)


# --- Telemetry device ---


@app.telemetry("counter", interval=60)
async def counter(ctx: cosalette.DeviceContext) -> dict[str, object]:
    """Read gas meter impulses and temperature."""
    meter = ctx.adapter(GasMeterPort)

    impulses = meter.read_impulses()
    temperature = meter.read_temperature()

    if impulses < 0:
        raise InvalidReadingError(f"Negative impulse count: {impulses}")

    return {
        "impulses": impulses,
        "temperature_celsius": temperature,
        "unit": "m³",
    }


# --- Command device ---


@app.command("valve")
async def handle_valve(
    payload: str, ctx: cosalette.DeviceContext
) -> dict[str, object]:
    """Control the gas valve via MQTT commands."""
    match payload:
        case "open":
            state = "open"
        case "close":
            state = "closed"
        case _:
            raise ValueError(
                f"Unknown command: {payload!r}. "
                f"Valid: open, close"
            )

    return {"state": state}


# --- Entry point ---


app.run()
```

1. `SerialGasMeter` is imported lazily — `pyserial` doesn't need to be installed
   on dev machines or in CI. The framework imports it at startup only in production.
2. `FakeGasMeter` is used when running `gas2mqtt --dry-run`. It returns simulated
   data without any hardware.

## 11. Test Suite

### Test Configuration

```python title="tests/conftest.py"
"""Shared pytest configuration for gas2mqtt tests."""

pytest_plugins = ["cosalette.testing._plugin"]
```

### Unit Tests: Counter

```python title="tests/unit/test_counter.py"
"""Unit tests for the counter telemetry device.

Test Techniques Used:
- Specification-based: Return-dict contract verification.
- Error Guessing: Invalid reading detection.
- Boundary Value Analysis: Edge case at impulses = 0.
"""

from __future__ import annotations

import pytest

from gas2mqtt.adapters import FakeGasMeter
from gas2mqtt.errors import InvalidReadingError
from gas2mqtt.ports import GasMeterPort


class StubGasMeter:
    """Stub with configurable return values."""

    def __init__(self, impulses: int = 42, temperature: float = 21.5) -> None:
        self.impulses = impulses
        self.temperature = temperature

    def connect(self, port: str, baud_rate: int = 9600) -> None:
        pass

    def read_impulses(self) -> int:
        return self.impulses

    def read_temperature(self) -> float:
        return self.temperature

    def close(self) -> None:
        pass


@pytest.mark.asyncio
async def test_counter_returns_impulse_dict(device_context):
    """Counter returns dict with impulses, temperature, and unit."""
    device_context._adapters[GasMeterPort] = StubGasMeter(
        impulses=100, temperature=22.0
    )

    from gas2mqtt.app import counter

    result = await counter(device_context)

    assert result == {
        "impulses": 100,
        "temperature_celsius": 22.0,
        "unit": "m³",
    }


@pytest.mark.asyncio
async def test_counter_rejects_negative_impulses(device_context):
    """Negative impulse count raises InvalidReadingError."""
    device_context._adapters[GasMeterPort] = StubGasMeter(impulses=-1)

    from gas2mqtt.app import counter

    with pytest.raises(InvalidReadingError, match="Negative impulse count"):
        await counter(device_context)


@pytest.mark.asyncio
async def test_counter_accepts_zero_impulses(device_context):
    """Zero is a valid impulse count (boundary value)."""
    device_context._adapters[GasMeterPort] = StubGasMeter(impulses=0)

    from gas2mqtt.app import counter

    result = await counter(device_context)
    assert result["impulses"] == 0
```

### Unit Tests: Valve

```python title="tests/unit/test_valve.py"
"""Unit tests for the valve command device.

Test Techniques Used:
- Decision Table: Command × expected state.
- Error Guessing: Invalid command handling.

Note: @app.command handlers are plain async functions, which
makes them trivially testable — no device loop, no closures.
"""

import pytest


@pytest.mark.asyncio
async def test_valve_open_command(device_context):
    """'open' command returns state dict with 'open'."""
    from gas2mqtt.app import handle_valve

    result = await handle_valve(
        payload="open",
        ctx=device_context,
    )
    assert result == {"state": "open"}


@pytest.mark.asyncio
async def test_valve_close_command(device_context):
    """'close' command returns state dict with 'closed'."""
    from gas2mqtt.app import handle_valve

    result = await handle_valve(
        payload="close",
        ctx=device_context,
    )
    assert result == {"state": "closed"}


@pytest.mark.asyncio
async def test_valve_rejects_invalid_command(device_context):
    """Unknown commands raise ValueError."""
    from gas2mqtt.app import handle_valve

    with pytest.raises(ValueError, match="Unknown command"):
        await handle_valve(
            payload="blink",
            ctx=device_context,
        )
```

Because `@app.command` handlers are standalone functions, unit testing is as simple
as calling the function directly with the arguments you want to test.

### Unit Tests: Error Types

```python title="tests/unit/test_errors.py"
"""Unit tests for gas2mqtt error type map.

Test Techniques Used:
- Decision Table: Exception class → error_type string mapping.
"""

from cosalette import build_error_payload
from gas2mqtt.errors import (
    InvalidReadingError,
    SensorTimeoutError,
    error_type_map,
)


def test_sensor_timeout_maps_correctly():
    """SensorTimeoutError → 'sensor_timeout'."""
    payload = build_error_payload(
        SensorTimeoutError("timed out"),
        error_type_map=error_type_map,
        device="counter",
    )
    assert payload.error_type == "sensor_timeout"


def test_invalid_reading_maps_correctly():
    """InvalidReadingError → 'invalid_reading'."""
    payload = build_error_payload(
        InvalidReadingError("bad value"),
        error_type_map=error_type_map,
        device="counter",
    )
    assert payload.error_type == "invalid_reading"


def test_unmapped_exception_falls_back_to_error():
    """Unmapped exceptions get default 'error' type."""
    payload = build_error_payload(
        RuntimeError("unexpected"),
        error_type_map=error_type_map,
    )
    assert payload.error_type == "error"
```

### Integration Test: Full Lifecycle

```python title="tests/integration/test_app.py"
"""Integration tests for the gas2mqtt application.

Test Techniques Used:
- State Transition Testing: Full app lifecycle.
"""

import asyncio

import pytest
import cosalette
from cosalette.testing import AppHarness

from gas2mqtt.adapters import FakeGasMeter
from gas2mqtt.ports import GasMeterPort


@pytest.mark.asyncio
async def test_full_lifecycle_publishes_telemetry():
    """Full app lifecycle: startup → telemetry → shutdown."""
    # Arrange
    harness = AppHarness.create(name="gas2mqtt")
    harness.app.adapter(GasMeterPort, FakeGasMeter)

    @harness.app.telemetry("counter", interval=1)
    async def counter(ctx: cosalette.DeviceContext) -> dict[str, object]:
        meter = ctx.adapter(GasMeterPort)
        return {"impulses": meter.read_impulses()}

    # Act — auto-shutdown after brief run
    async def shutdown_after_delay():
        await asyncio.sleep(0.1)
        harness.trigger_shutdown()

    asyncio.create_task(shutdown_after_delay())
    await harness.run()

    # Assert
    messages = harness.mqtt.get_messages_for("gas2mqtt/counter/state")
    assert len(messages) >= 1


@pytest.mark.asyncio
async def test_valve_command_publishes_state():
    """Valve command handler publishes state on command."""
    # Arrange
    harness = AppHarness.create(name="gas2mqtt")

    @harness.app.command("valve")
    async def handle_valve(
        payload: str, ctx: cosalette.DeviceContext
    ) -> dict[str, object]:
        return {"state": payload}

    # Act
    async def shutdown_after_delay():
        await asyncio.sleep(0.1)
        harness.trigger_shutdown()

    asyncio.create_task(shutdown_after_delay())
    await harness.run()

    # Assert — command handlers don't publish on startup, so we check
    # that the device registered successfully
    assert "valve" in [c.name for c in harness.app._commands]
```

## 12. Running the Application

### With a `.env` File

```bash title=".env"
# MQTT broker
GAS2MQTT_MQTT__HOST=broker.local
GAS2MQTT_MQTT__PORT=1883
GAS2MQTT_MQTT__USERNAME=gas2mqtt
GAS2MQTT_MQTT__PASSWORD=s3cret

# Logging
GAS2MQTT_LOGGING__LEVEL=INFO
GAS2MQTT_LOGGING__FORMAT=json

# App settings
GAS2MQTT_SERIAL_PORT=/dev/ttyUSB0
GAS2MQTT_BAUD_RATE=9600
GAS2MQTT_COUNTER_INTERVAL=60
```

### Production

```bash
# Run normally
uv run gas2mqtt

# Override log level
uv run gas2mqtt --log-level DEBUG --log-format text

# Use a custom .env file
uv run gas2mqtt --env-file /etc/gas2mqtt/.env
```

### Dry-Run Mode

```bash
# Uses FakeGasMeter instead of SerialGasMeter
uv run gas2mqtt --dry-run
```

Dry-run mode resolves `FakeGasMeter` for `GasMeterPort`, so the app runs without
hardware. This is useful for development, CI testing, and demo setups.

### Docker Deployment

```dockerfile title="Dockerfile"
FROM python:3.14-slim

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv==0.6.6 && uv sync --frozen

COPY src/ src/
COPY .env .env

CMD ["uv", "run", "gas2mqtt"]
```

```yaml title="docker-compose.yml"
services:
  gas2mqtt:
    build: .
    restart: unless-stopped
    devices:
      - /dev/ttyUSB0:/dev/ttyUSB0  # Pass through serial device
    environment:
      GAS2MQTT_MQTT__HOST: mosquitto
      GAS2MQTT_LOGGING__FORMAT: json
    depends_on:
      - mosquitto

  mosquitto:
    image: eclipse-mosquitto:2
    ports:
      - "1883:1883"
    volumes:
      - mosquitto-data:/mosquitto/data
      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf

volumes:
  mosquitto-data:
```

!!! tip "Production checklist"

    - [ ] Use `json` log format for container log aggregators
    - [ ] Set `QoS 1` (default) for at-least-once delivery
    - [ ] Configure MQTT authentication
    - [ ] Mount the serial device into the container
    - [ ] Set `restart: unless-stopped` for daemon resilience
    - [ ] Monitor the `gas2mqtt/status` topic for LWT availability

## Summary

Here's what each piece does and how they connect:

```text
┌────────────────────────────────────────────────┐
│                  gas2mqtt App                  │
├───────────────┬────────────────────────────────┤
│ Settings      │ Gas2MqttSettings               │
│               │   serial_port, baud_rate, etc. │
├───────────────┼────────────────────────────────┤
│ Port          │ GasMeterPort (Protocol)        │
├───────────────┼────────────────────────────────┤
│ Adapters      │ SerialGasMeter (real)          │
│               │ FakeGasMeter   (dry-run)       │
├───────────────┼────────────────────────────────┤
│ Lifespan      │ lifespan (asynccontextmanager) │
├───────────────┼────────────────────────────────┤
│ Devices       │ counter  (telemetry, 60s)      │
│               │ valve    (command, open/close) │
├───────────────┼────────────────────────────────┤
│ Error Types   │ SensorTimeoutError             │
│               │ InvalidReadingError            │
│               │ ConnectionLostError            │
├───────────────┼────────────────────────────────┤
│ MQTT Topics   │ gas2mqtt/counter/state         │
│               │ gas2mqtt/valve/state           │
│               │ gas2mqtt/valve/set             │
│               │ gas2mqtt/error                 │
│               │ gas2mqtt/{device}/error        │
│               │ gas2mqtt/status                │
└───────────────┴────────────────────────────────┘
```

---

## See Also

- [Telemetry Device](telemetry-device.md) — deep dive into `@app.telemetry`
- [Command & Control Device](command-device.md) — deep dive into `@app.command` and
  `@app.device`
- [Configuration](configuration.md) — settings, `.env`, CLI overrides
- [Hardware Adapters](adapters.md) — ports, adapters, dry-run
- [Lifespan](lifespan.md) — startup/shutdown via lifespan pattern
- [Testing](testing.md) — pytest plugin, AppHarness, test doubles
- [Custom Error Types](error-types.md) — error classification
- [Architecture](../concepts/architecture.md) — framework architecture overview
