Reference¶
Complete lookup reference — settings, MQTT topics, commands, CLI options, error types, and API documentation.
Settings Reference¶
All settings are read from environment variables prefixed with JEELINK2MQTT_.
A .env file in the working directory is loaded automatically.
Nested settings use __ as delimiter (e.g. JEELINK2MQTT_MQTT__HOST).
Application Settings¶
| Setting | Env Variable | Type | Default | Description |
|---|---|---|---|---|
serial_port |
JEELINK2MQTT_SERIAL_PORT |
str |
/dev/ttyUSB0 |
Serial port path (must start with /dev/) |
baud_rate |
JEELINK2MQTT_BAUD_RATE |
int |
57600 |
Serial baud rate |
sensors |
JEELINK2MQTT_SENSORS |
list[object] |
[] |
Sensor definitions (JSON array) |
staleness_timeout_seconds |
JEELINK2MQTT_STALENESS_TIMEOUT_SECONDS |
float |
600.0 |
Global staleness timeout in seconds (min: 60) |
median_filter_window |
JEELINK2MQTT_MEDIAN_FILTER_WINDOW |
int |
7 |
Median filter window size (3–21, must be odd) |
heartbeat_interval_seconds |
JEELINK2MQTT_HEARTBEAT_INTERVAL_SECONDS |
float |
180.0 |
Heartbeat re-publish interval in seconds (min: 10) |
Inherited cosalette Settings¶
| Setting | Env Variable | Type | Default | Description |
|---|---|---|---|---|
mqtt.host |
JEELINK2MQTT_MQTT__HOST |
str |
localhost |
MQTT broker hostname |
mqtt.port |
JEELINK2MQTT_MQTT__PORT |
int |
1883 |
MQTT broker port |
mqtt.username |
JEELINK2MQTT_MQTT__USERNAME |
str |
"" |
MQTT username |
mqtt.password |
JEELINK2MQTT_MQTT__PASSWORD |
str |
"" |
MQTT password |
Validators¶
| Field | Constraint |
|---|---|
serial_port |
Must start with /dev/ |
median_filter_window |
Must be odd, between 3 and 21 |
staleness_timeout_seconds |
Minimum 60.0 |
heartbeat_interval_seconds |
Minimum 10.0 |
Sensor Configuration Fields¶
Each entry in the JEELINK2MQTT_SENSORS JSON array supports:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
str |
Yes | — | Logical sensor name (e.g. "office", "outdoor") |
temp_offset |
float |
No | 0.0 |
Calibration offset added to temperature (°C) |
humidity_offset |
float |
No | 0.0 |
Calibration offset added to humidity (percentage points) |
staleness_timeout |
float \| null |
No | null |
Per-sensor staleness override in seconds (null = use global) |
Example:
JEELINK2MQTT_SENSORS='[
{"name": "office", "temp_offset": -0.5, "humidity_offset": 2.0},
{"name": "outdoor", "staleness_timeout": 900},
{"name": "bedroom"}
]'
MQTT Topic Map¶
discoverable=False is not access control
A channel marked discoverable=False is omitted from Home Assistant discovery
output. It is still subscribed and still acts on what it receives. The flag
changes what Home Assistant is told about, not who may publish. Anyone who can
reach the broker can still drive these topics, and the bundled broker in
compose.yml is plaintext with allow_anonymous true. Restrict
access with broker ACLs and <PREFIX>_MQTT__TLS=true, not with this flag.
| Topic | Direction | Retained | Payload |
|---|---|---|---|
jeelink2mqtt/{sensor}/state |
Out | Yes | {temperature, humidity, low_battery, timestamp} |
jeelink2mqtt/{sensor}/availability |
Out | Yes | "online" or "offline" |
jeelink2mqtt/raw/state |
Out | No | {sensor_id, temperature, humidity, low_battery, timestamp} |
jeelink2mqtt/mapping/state |
Out | Yes | {sensor_name: {sensor_id, mapped_at, last_seen}} |
jeelink2mqtt/mapping/event |
Out | No | {event_type, sensor_name, old_sensor_id, new_sensor_id, timestamp, reason} |
jeelink2mqtt/mapping/set |
In | No | {command, ...params} |
Sensor State Payload¶
{
"temperature": 21.3,
"humidity": 52,
"low_battery": false,
"timestamp": "2026-03-04T10:15:00+00:00"
}
Mapping Event Payload¶
{
"event_type": "auto_adopt",
"sensor_name": "office",
"old_sensor_id": null,
"new_sensor_id": 42,
"timestamp": "2026-03-04T10:15:00+00:00",
"reason": "Auto-adopted sensor ID 42 for 'office'"
}
Event types: auto_adopt, manual_assign, manual_reset, reset_all.
Framework Topics¶
Alongside the topics above, cosalette itself publishes two framework-owned topics. Both are always on — no setting disables them — retained, QoS 1, and republished byte-identically on every broker connect.
| Topic | Payload | Retain | QoS |
|---|---|---|---|
jeelink2mqtt/_meta/registry |
Canonical AsyncAPI 3.0.0 document | yes | 1 |
jeelink2mqtt/_meta/state_model_drift |
state_model drift snapshot JSON |
yes | 1 |
Registry (jeelink2mqtt/_meta/registry)¶
The canonical AsyncAPI document describing every channel jeelink2mqtt publishes and subscribes to. Inbound command channels are stripped from the published copy so the command surface is not exposed to anyone who can subscribe on a shared broker.
State Model Drift (jeelink2mqtt/_meta/state_model_drift)¶
A machine-readable snapshot of state_model declaration drift (ADR-069): a handler
whose state_model= argument disagrees with its return type annotation. The topic is
published even when there is no drift — a clean app publishes drift_count: 0 rather
than omitting the topic, so "no drift" is distinguishable from "never ran a version
that publishes this topic".
| Field | Type | Description |
|---|---|---|
schema_version |
integer | Envelope version; bumped only on an incompatible payload change |
drift_count |
integer | Number of handlers with a declaration/annotation conflict |
entries[].handler |
string | Registered handler name |
entries[].archetype |
string | "telemetry" or "command" |
entries[].kind |
string | Drift kind — currently only "annotation_conflict" |
entries[].declared_model |
string | The state_model= class name declared on the handler |
entries[].effective_annotation |
string | The handler's actual return type annotation |
Fleet-wide scraping
One subscription across a whole broker distinguishes a healthy app from one that predates this topic:
An app publishing drift_count: 0 is healthy. An app with no retained message
on this topic at all has not been upgraded past cosalette 0.9.0.
ACL guidance¶
Both topics disclose handler names, channel addresses, and payload schemas. If you
run a production broker ACL file, protect _meta/# the same way you protect
_meta/registry. The mosquitto.conf shipped with jeelink2mqtt is
dev-only (allow_anonymous true, no ACL file), so there is nothing to change
in-repo — this note only applies if you deploy your own broker ACLs.
Command Reference¶
Commands are sent as JSON to jeelink2mqtt/mapping/set. Responses are
published to jeelink2mqtt/mapping/state.
| Command | Parameters | Description |
|---|---|---|
assign |
sensor_name (str), sensor_id (int) |
Manually assign an ephemeral ID to a logical sensor name |
reset |
sensor_name (str) |
Remove the mapping for a single sensor |
reset_all |
(none) | Clear all sensor mappings |
list_unknown |
(none) | List recently-seen unmapped sensor IDs |
assign Response¶
{
"status": "ok",
"event": {
"event_type": "manual_assign",
"sensor_name": "office",
"old_sensor_id": null,
"new_sensor_id": 42,
"reason": "Manually assigned sensor ID 42 to 'office'"
}
}
reset Response¶
{
"status": "ok",
"event": {
"event_type": "manual_reset",
"sensor_name": "office",
"old_sensor_id": 42
}
}
reset_all Response¶
list_unknown Response¶
{
"status": "ok",
"unknown_sensors": {
"42": {
"temperature": 21.3,
"humidity": 55,
"low_battery": false,
"timestamp": "2026-03-04T10:15:00+00:00"
}
}
}
Error Responses¶
{"error": "Invalid JSON payload"}
{"error": "Unknown command: foo"}
{"error": "assign requires 'sensor_name' and 'sensor_id'"}
{"error": "Sensor ID 42 is already mapped to 'outdoor', cannot assign to 'office'"}
{"error": "Unknown sensor name 'foo' — must be one of the configured sensors"}
CLI Options¶
jeelink2mqtt uses the cosalette CLI framework (Typer-based). Available flags:
| Flag | Description |
|---|---|
--dry-run |
Use fake adapter — no hardware required |
--version |
Print version and exit |
--log-level |
Set logging verbosity: DEBUG, INFO, WARNING, ERROR |
--env-file |
Path to .env file (default: .env in working directory) |
Error Types¶
Domain exceptions are mapped to MQTT error type strings for structured error reporting:
| Exception | MQTT Error Type | Description |
|---|---|---|
SerialConnectionError |
serial_connection |
JeeLink serial port unavailable or disconnected |
FrameParseError |
frame_parse |
Received data doesn't match LaCrosse frame format |
MappingConflictError |
mapping_conflict |
ID already assigned to another sensor |
StalenessTimeoutError |
staleness_timeout |
Sensor hasn't sent readings within the staleness window |
UnknownSensorError |
unknown_sensor |
Reading from an unrecognised/unmapped sensor ID |
API Reference¶
Auto-generated from source docstrings.
Models¶
jeelink2mqtt.models
¶
Domain models for jeelink2mqtt.
Immutable value objects representing sensor readings, configuration,
mapping state, and mapping lifecycle events. All models use frozen
dataclasses with __slots__ for memory efficiency and immutability
guarantees.
SensorStateModel
¶
Bases: BaseModel
Contract for the per-sensor {name}/state retained payload.
Declared as state_model= on the sensor_entity device
(:mod:jeelink2mqtt.main) so every ctx.publish_state() call is
validated and normalised against this shape (ADR-046).
The temperature/humidity/low_battery fields carry
x-cosalette-consumer metadata (via
:func:cosalette.schema.consumer) so app.discovery() — and the
offline cosalette schema ha-discovery path — emit one Home
Assistant MQTT discovery entity per field, per configured sensor
(ADR-059). timestamp is publish-freshness bookkeeping,
not a user-facing entity, so it is left unannotated.
SensorReading
dataclass
¶
SensorReading(
sensor_id: int,
temperature: float,
humidity: int,
low_battery: bool,
timestamp: datetime = (lambda: now(UTC))(),
)
Raw reading received from the JeeLink USB receiver.
Represents a single LaCrosse frame decoded into typed fields.
The sensor_id is ephemeral — it changes on every battery swap,
so higher-level code must resolve it to a logical name via the
sensor registry (see ADR-002).
SensorConfig
dataclass
¶
SensorConfig(
name: str,
temp_offset: float = 0.0,
humidity_offset: float = 0.0,
staleness_timeout: float | None = None,
)
Per-sensor configuration loaded from application settings.
Each configured sensor gets a logical name (e.g. "office",
"outdoor") that remains stable across battery swaps and ID
changes. Calibration offsets allow compensating for individual
sensor inaccuracies.
temp_offset
class-attribute
instance-attribute
¶
Calibration offset added to temperature readings (°C).
humidity_offset
class-attribute
instance-attribute
¶
Calibration offset added to humidity readings (percentage points).
staleness_timeout
class-attribute
instance-attribute
¶
Per-sensor staleness override in seconds (None = use global).
SensorMapping
dataclass
¶
Runtime mapping state for one sensor.
Tracks which ephemeral LaCrosse ID is currently associated with a logical sensor name. Updated when a battery swap causes a new ID to appear (auto-adopt) or via manual assignment.
MappingEvent
dataclass
¶
MappingEvent(
event_type: str,
sensor_name: str,
old_sensor_id: int | None,
new_sensor_id: int | None,
timestamp: datetime,
reason: str,
)
Immutable event recording a mapping change.
Produced by the sensor registry whenever a mapping is created, changed, or reset. Can be published to MQTT for observability or persisted for audit trails.
Settings¶
jeelink2mqtt.settings
¶
Application configuration for jeelink2mqtt.
Extends cosalette's Settings base with JeeLink-specific fields
for serial communication, sensor definitions, and signal-processing
parameters. Loaded from environment variables prefixed with
JEELINK2MQTT_ and/or a .env file.
SensorConfigSettings
¶
Bases: BaseModel
Pydantic model for a single sensor definition in the config.
Mirrors :class:~jeelink2mqtt.models.SensorConfig but lives in
the settings layer so pydantic-settings can deserialise it from
environment variables or config files.
temp_offset
class-attribute
instance-attribute
¶
Calibration offset for temperature (°C).
humidity_offset
class-attribute
instance-attribute
¶
Calibration offset for humidity (percentage points).
staleness_timeout
class-attribute
instance-attribute
¶
Per-sensor staleness override in seconds (None = use global).
name_must_be_valid_topic_segment
classmethod
¶
Reject names that would form invalid MQTT topic segments.
Source code in packages/src/jeelink2mqtt/settings.py
Jeelink2MqttSettings
¶
Bases: Settings
Root settings for the jeelink2mqtt application.
Inherits MQTT and logging settings from cosalette and adds hardware, sensor, and signal-processing configuration.
Environment variable examples::
JEELINK2MQTT_SERIAL_PORT=/dev/ttyUSB0
JEELINK2MQTT_BAUD_RATE=57600
JEELINK2MQTT_STALENESS_TIMEOUT_SECONDS=600
JEELINK2MQTT_MQTT__HOST=broker.local
Errors¶
jeelink2mqtt.errors
¶
Domain exceptions for jeelink2mqtt.
Each exception maps to a structured MQTT error type via the
error_type_map dict, used by cosalette's error handling system.
error_type_map
module-attribute
¶
error_type_map: dict[type[Exception], str] = {
SerialConnectionError: "serial_connection",
FrameParseError: "frame_parse",
MappingConflictError: "mapping_conflict",
StalenessTimeoutError: "staleness_timeout",
UnknownSensorError: "unknown_sensor",
}
Mapping from exception types to MQTT error-topic string identifiers.
Used by cosalette's error publisher to route structured error payloads to the correct MQTT sub-topic.
SerialConnectionError
¶
Bases: Exception
JeeLink serial port unavailable or disconnected.
FrameParseError
¶
Bases: Exception
Received data doesn't match the expected LaCrosse frame format.
MappingConflictError
¶
Bases: Exception
Attempted to map an ID that's already assigned to another sensor.
StalenessTimeoutError
¶
Bases: Exception
Sensor hasn't sent readings within the staleness window.
UnknownSensorError
¶
Bases: Exception
Received a reading from an unrecognised / unmapped sensor ID.
Ports¶
jeelink2mqtt.ports
¶
Protocol ports for jeelink2mqtt hardware abstraction.
Follows hexagonal architecture (ADR-003): device code depends on Protocol ports, never on concrete implementations.
JeeLinkPort
¶
Bases: HealthCheckable, Protocol
Hardware abstraction for the JeeLink USB receiver.
Concrete implementations wrap the serial connection and frame parsing. Application code depends only on this protocol, making it straightforward to substitute a mock or simulator for testing.
open
async
¶
close
async
¶
start_scan
async
¶
stop_scan
async
¶
register_callback
¶
register_callback(
callback: Callable[[SensorReading], None],
) -> None
Register a callback invoked for each decoded sensor frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback
|
Callable[[SensorReading], None]
|
Function called with a :class: |
required |
Source code in packages/src/jeelink2mqtt/ports.py
set_led
¶
Control the JeeLink on-board LED.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
enabled
|
bool
|
|
required |
__aiter__
¶
__aiter__() -> AsyncIterator[SensorReading]
__anext__
async
¶
__anext__() -> SensorReading
__aenter__
async
¶
__aenter__() -> JeeLinkPort
__aexit__
async
¶
SensorRegistryPort
¶
Bases: Protocol
Read-only access to the sensor ID → name registry.
Used by telemetry-reading code to resolve ephemeral LaCrosse IDs to stable logical names and to check liveness. Write operations (adopt, assign, reset) live on the concrete registry implementation.
resolve
¶
Resolve an ephemeral sensor ID to a logical name.
Returns:
| Type | Description |
|---|---|
str | None
|
The sensor name, or |
is_stale
¶
Check whether a sensor has exceeded its staleness timeout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sensor_name
|
str
|
Logical sensor name. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in packages/src/jeelink2mqtt/ports.py
get_mapping
¶
get_mapping(sensor_name: str) -> SensorMapping | None
Get the current mapping for a logical sensor name.
Returns:
| Type | Description |
|---|---|
SensorMapping | None
|
The active :class: |
get_all_mappings
¶
get_all_mappings() -> dict[str, SensorMapping]
Get all active mappings.
Returns:
| Type | Description |
|---|---|
dict[str, SensorMapping]
|
Dictionary of |
get_unmapped_ids
¶
get_unmapped_ids() -> dict[int, SensorReading]
Get recently-seen sensor IDs that are not yet mapped.
Returns:
| Type | Description |
|---|---|
dict[int, SensorReading]
|
Dictionary of |
Calibration¶
jeelink2mqtt.calibration
¶
Per-sensor calibration offset application.
Applies configurable temperature and humidity offsets to filtered sensor readings, compensating for individual sensor inaccuracies.
apply_calibration
¶
apply_calibration(
reading: SensorReading, config: SensorConfig
) -> SensorReading
Return a new reading with calibration offsets applied.
Temperature is offset directly; humidity uses math.floor(x + 0.5)
(standard half-up rounding, not Python's default banker's
rounding) and is clamped to 0–100.
Uses :func:dataclasses.replace so that any future fields added to
:class:SensorReading are preserved automatically.
Source code in packages/src/jeelink2mqtt/calibration.py
Filters¶
jeelink2mqtt.filters
¶
Per-sensor signal filtering.
Manages a bank of MedianFilter instances — one per active sensor ID — for outlier rejection on temperature and humidity readings.
FilterBank
¶
Maintains per-sensor median filters for temperature and humidity.
A filter pair is lazily created the first time a sensor ID is seen. Filters can be individually reset (e.g. after a mapping change) or bulk-cleared.
Source code in packages/src/jeelink2mqtt/filters.py
filter
¶
filter(reading: SensorReading) -> tuple[float, float]
Apply median filtering to a sensor reading.
Returns:
| Type | Description |
|---|---|
float
|
|
float
|
sliding-window median has been applied. |
Source code in packages/src/jeelink2mqtt/filters.py
reset
¶
Remove filters for a sensor ID (e.g. on mapping change).