Skip to content

Devices

cosalette device handlers — the bridge between domain logic and the MQTT framework.

Gas Counter

gas2mqtt.devices.gas_counter

Gas counter telemetry — stateful trigger detection and counting.

Uses shared GasCounterState instance created by the @app.state provider in main.py and injected via DI. Telemetry handler is called periodically (poll_interval). Command handler processes MQTT consumption updates.

Scheduled runs: read magnetometer, detect trigger edges, count ticks. Command runs: set consumption value from MQTT command payload.

State persistence

GasCounterState wraps a DeviceStore. stage_state() explicitly calls store.save() to persist counter and consumption on changes. Counter and consumption survive restarts.

MQTT state payload

{"counter": 42, "trigger": "CLOSED"} or with consumption:

MQTT command payload (on gas2mqtt/consumption/set):

GasCounterReading dataclass

GasCounterReading(
    counter: Annotated[
        int,
        Field(
            json_schema_extra=consumer(
                display_name="Gas Pulse Counter",
                state_class="total_increasing",
                icon="mdi:counter",
            )
        ),
    ],
    trigger: str,
    consumption_m3: Annotated[
        float | None,
        Field(
            json_schema_extra=consumer(
                display_name="Gas Consumption",
                device_class="gas",
                unit="m³",
                state_class="total_increasing",
            )
        ),
    ] = None,
)

Published gas-counter telemetry payload.

Typed state_model for the gas_counter telemetry channel so cosalette schema init emits typed properties carrying x-cosalette-consumer metadata for Home Assistant discovery. The metadata rides on each field via :func:pydantic.Field's json_schema_extra, so it survives task gas2mqtt:schema:generate (TypeAdapter(model).json_schema() preserves it) — no post-generation hand-application. Mirrors the dict returned by :meth:GasCounterState.build_state.

Attributes:

Name Type Description
counter Annotated[int, Field(json_schema_extra=consumer(display_name='Gas Pulse Counter', state_class='total_increasing', icon='mdi:counter'))]

Raw pulse count (wraps at COUNTER_MODULUS).

trigger str

Schmitt-trigger edge state — "CLOSED" or "OPEN".

consumption_m3 Annotated[float | None, Field(json_schema_extra=consumer(display_name='Gas Consumption', device_class='gas', unit='m³', state_class='total_increasing'))]

Accumulated gas volume in m³, or None when the consumption tracker is not configured.

GasCounterState

GasCounterState(
    trigger: SchmittTrigger,
    counter: int,
    consumption: ConsumptionTracker | None,
    store: DeviceStore,
)

Mutable state container for the gas counter.

Created once by make_gas_counter() called from the @app.state provider, persists across all scheduled and triggered telemetry runs.

Source code in packages/src/gas2mqtt/devices/gas_counter.py
def __init__(
    self,
    trigger: SchmittTrigger,
    counter: int,
    consumption: ConsumptionTracker | None,
    store: DeviceStore,
) -> None:
    self.trigger = trigger
    self.counter = counter
    self.consumption = consumption
    self.store = store

make_gas_counter

make_gas_counter(
    settings: Gas2MqttSettings,
    store: DeviceStore,
    logger: Logger,
) -> GasCounterState

Factory — creates stateful domain objects.

Called once by the @app.state provider before the telemetry loop. The returned GasCounterState is injected into gas_counter() on every scheduled and triggered run.

Source code in packages/src/gas2mqtt/devices/gas_counter.py
def make_gas_counter(
    settings: Gas2MqttSettings,
    store: DeviceStore,
    logger: logging.Logger,
) -> GasCounterState:
    """Factory — creates stateful domain objects.

    Called once by the @app.state provider before the telemetry loop.
    The returned GasCounterState is injected into gas_counter() on every
    scheduled and triggered run.
    """
    store.load()
    trigger = SchmittTrigger(settings.trigger_level, settings.trigger_hysteresis)
    counter = _restore_counter(store, logger)
    consumption = _restore_consumption(store, settings, logger)
    return GasCounterState(trigger, counter, consumption, store)

gas_counter async

gas_counter(
    state: GasCounterState,
    magnetometer: MagnetometerPort,
    logger: Logger,
) -> dict[str, object] | None

Gas counter telemetry handler (read-only).

Called periodically to read magnetometer and count ticks. Returns dict to publish, or None to skip.

Source code in packages/src/gas2mqtt/devices/gas_counter.py
async def gas_counter(
    state: GasCounterState,
    magnetometer: MagnetometerPort,
    logger: logging.Logger,
) -> dict[str, object] | None:
    """Gas counter telemetry handler (read-only).

    Called periodically to read magnetometer and count ticks.
    Returns dict to publish, or None to skip.
    """
    return _poll(state, magnetometer, logger)

update_consumption async

update_consumption(
    payload: str, state: GasCounterState, logger: Logger
) -> dict[str, object] | None

Handle inbound MQTT command to set consumption value.

Expects JSON payload: {"consumption_m3": } Publishes updated state on success; returns None if disabled or invalid.

Source code in packages/src/gas2mqtt/devices/gas_counter.py
async def update_consumption(
    payload: str,
    state: GasCounterState,
    logger: logging.Logger,
) -> dict[str, object] | None:
    """Handle inbound MQTT command to set consumption value.

    Expects JSON payload: {"consumption_m3": <float>}
    Publishes updated state on success; returns None if disabled or invalid.
    """
    if state.consumption is None:
        logger.warning("Consumption command received but tracking is disabled")
        return None

    if not payload.strip():
        return None

    try:
        data = json.loads(payload)
    except json.JSONDecodeError:
        logger.warning("Ignoring invalid JSON in consumption command")
        return None

    if not isinstance(data, dict):
        logger.warning("Ignoring non-object JSON in consumption command")
        return None

    if "consumption_m3" in data:
        consumption_m3 = _parse_consumption_m3(data["consumption_m3"], logger)
        if consumption_m3 is None:
            return None
        state.consumption.set_consumption(consumption_m3)
        logger.info("Consumption set to %.3f m³", state.consumption.consumption_m3)
        state.stage_state()
        return state.build_state()
    return None

Temperature

gas2mqtt.devices.temperature

Temperature telemetry — PT1-filtered, calibrated sensor readings.

Reads the raw temperature from the magnetometer's built-in sensor, applies a linear calibration (temp_scale * raw + temp_offset), then smooths via an exponentially-weighted PT1 (first-order lag) filter.

The OnChange publish strategy ensures readings are only published when the filtered value shifts by more than 0.05 °C, avoiding MQTT chatter for sensor noise.

MQTT state payload

{"temperature": 22.5}

TemperatureReading dataclass

TemperatureReading(
    temperature: Annotated[
        float,
        Field(
            json_schema_extra=consumer(
                device_class="temperature",
                unit="°C",
                state_class="measurement",
            )
        ),
    ],
)

Published temperature telemetry payload.

Typed state_model for the temperature telemetry channel so cosalette schema init emits a typed property carrying x-cosalette-consumer metadata for Home Assistant discovery. The metadata rides on the field via :func:pydantic.Field's json_schema_extra, so it survives task gas2mqtt:schema:generate (TypeAdapter(model).json_schema() preserves it) — no post-generation hand-application.

Attributes:

Name Type Description
temperature Annotated[float, Field(json_schema_extra=consumer(device_class='temperature', unit='°C', state_class='measurement'))]

PT1-filtered temperature in degrees Celsius.

make_pt1

make_pt1(settings: Gas2MqttSettings) -> Pt1Filter

Create PT1 filter from settings for temperature smoothing.

This is the init= factory: cosalette calls it once before the handler loop and injects the returned Pt1Filter by type.

Source code in packages/src/gas2mqtt/devices/temperature.py
def make_pt1(settings: Gas2MqttSettings) -> Pt1Filter:
    """Create PT1 filter from settings for temperature smoothing.

    This is the ``init=`` factory: cosalette calls it once before the
    handler loop and injects the returned ``Pt1Filter`` by type.
    """
    return Pt1Filter(tau=settings.smoothing_tau, dt=settings.temperature_interval)

temperature async

temperature(
    magnetometer: MagnetometerPort,
    settings: Gas2MqttSettings,
    pt1: Pt1Filter,
) -> dict[str, object]

Read temperature, calibrate, filter, and return state dict.

Parameters:

Name Type Description Default
magnetometer MagnetometerPort

Sensor adapter (injected by cosalette DI).

required
settings Gas2MqttSettings

Application settings with calibration coefficients.

required
pt1 Pt1Filter

PT1 filter instance (created by :func:make_pt1).

required

Returns:

Type Description
dict[str, object]

{"temperature": <rounded float>} — published to MQTT by

dict[str, object]

the framework when the OnChange strategy fires.

Source code in packages/src/gas2mqtt/devices/temperature.py
async def temperature(
    magnetometer: MagnetometerPort,
    settings: Gas2MqttSettings,
    pt1: Pt1Filter,
) -> dict[str, object]:
    """Read temperature, calibrate, filter, and return state dict.

    Args:
        magnetometer: Sensor adapter (injected by cosalette DI).
        settings: Application settings with calibration coefficients.
        pt1: PT1 filter instance (created by :func:`make_pt1`).

    Returns:
        ``{"temperature": <rounded float>}`` — published to MQTT by
        the framework when the ``OnChange`` strategy fires.
    """
    reading = magnetometer.read()
    raw_celsius = settings.temp_scale * reading.temperature_raw + settings.temp_offset
    return {"temperature": round(pt1.update(raw_celsius), 1)}

Magnetometer

gas2mqtt.devices.magnetometer

Magnetometer debug telemetry — raw 3-axis magnetic field readings.

Publishes raw Bx, By, Bz values from the QMC5883L sensor at the gas counter's poll interval. Disabled by default — enable via the enable_debug_device setting for calibration and troubleshooting.

MQTT state payload

{"bx": -1234, "by": 567, "bz": -4567}

magnetometer async

magnetometer(
    magnetometer: MagnetometerPort,
) -> dict[str, object]

Read and return raw magnetic field values.

Parameters:

Name Type Description Default
magnetometer MagnetometerPort

Sensor adapter (injected by cosalette DI).

required

Returns:

Type Description
dict[str, object]

{"bx": int, "by": int, "bz": int} — published to MQTT

dict[str, object]

on every poll cycle.

Source code in packages/src/gas2mqtt/devices/magnetometer.py
async def magnetometer(
    magnetometer: MagnetometerPort,
) -> dict[str, object]:
    """Read and return raw magnetic field values.

    Args:
        magnetometer: Sensor adapter (injected by cosalette DI).

    Returns:
        ``{"bx": int, "by": int, "bz": int}`` — published to MQTT
        on every poll cycle.
    """
    reading = magnetometer.read()
    return {"bx": reading.bx, "by": reading.by, "bz": reading.bz}