Mastodon

Integrating LaCrosse Sensors into Home Assistant via JeeLink on FreeBSD



LaCrosse Sensor

Home Assistant runs great inside a bhyve VM on FreeBSD, but USB passthrough into bhyve is not straightforward. When I needed to integrate a ConBee II Zigbee dongle, the solution was to run zigbee2mqtt inside a FreeBSD jail - where USB devices can be exposed via devfs rules - and forward everything over MQTT. This same pattern turns out to work perfectly for other USB receivers, like the JeeLink with its legacy LaCrosse temperature and humidity sensors.

The JeeLink is a small USB stick with an RFM12B radio module running the LaCrosseITPlusReader sketch. It picks up transmissions from LaCrosse TX-series sensors on 868 MHz, decodes them, and outputs the raw data over a serial port. All we need is a small bridge script that reads the serial data and publishes it to MQTT.

The Stack

[ LaCrosse TX sensors ]
        |  868 MHz
        v
[ JeeLink USB (FT232R) ] --> /dev/cuaU1
        |
        v
[ FreeBSD Jail ]
  lacrosse2mqtt.py
        |  MQTT
        v
[ Mosquitto Broker ]
        |
        v
[ Home Assistant (bhyve VM) ]

The jail already exists for zigbee2mqtt, so adding a second serial device and a Python script is minimal additional overhead.

Identifying the Device

FreeBSD recognizes the JeeLink’s FTDI chip out of the box:

usbconfig list
ugen0.4: <FT232 Serial (UART) IC Future Technology Devices International, Ltd> at usbus0

The kernel attaches it via the uftdi driver, which maps to a /dev/cuaU* serial device. If you already have another USB serial device (like a Zigbee dongle on /dev/cuaU0), the JeeLink will appear as /dev/cuaU1.

Passing the Device into the Jail

In /etc/devfs.rules, extend the jail’s ruleset to include the new serial port:

[devfsrules_jail_z2m=10]
add include $devfsrules_hide_all
add include $devfsrules_unhide_login
add path 'cuaU0' unhide
add path 'cuaU1' unhide
add path 'ugen*' unhide

The jail configuration in /etc/jail.conf references this ruleset:

z2m {
    # ... other settings ...
    devfs_ruleset = 10;
    enforce_statfs = 1;
    allow.mount.devfs;
}

Restart the jail for the new device to appear inside it.

Verifying the Serial Connection

Inside the jail, use cu to confirm the JeeLink is transmitting and the baud rate is correct. The LaCrosseITPlusReader sketch runs at 57600 baud:

cu -l /dev/cuaU1 -s 57600

If everything is working, you’ll see a header line followed by sensor readings:

[LaCrosseITPlusReader.10.1s (RFM12B f:868300 r:17241)]
OK 9 8 1 4 22 106
OK 9 44 129 4 182 52

Exit cu with ~. and move on. If you see garbage, double-check the baud rate - this was by far the most common cause of confusion during my setup.

Decoding the Data Format

Each OK 9 line from the JeeLink represents a sensor reading:

OK 9 <ID> <flags> <temp_high> <temp_low> <humidity>

Temperature is a 16-bit value divided by 10, offset by 100:

temp = (temp_high * 256 + temp_low) / 10.0 - 100.0

For example, bytes 4 182 decode to (4 * 256 + 182) / 10.0 - 100.0 = 20.6°C.

Humidity is the raw byte value in percent. A value of 52 means 52% relative humidity.

Flags encode battery status: bit 7 (0x80) indicates a new battery was just inserted, bit 0 (0x01) signals a weak battery.

The Bridge Script

Install the dependencies inside the jail:

pkg install py311-serial py311-paho-mqtt

Then create /usr/local/bin/lacrosse2mqtt.py:

#!/usr/bin/env python3
import serial
import paho.mqtt.client as mqtt
import time
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')

SERIAL_PORT = "/dev/cuaU1"
BAUD        = 57600
MQTT_HOST   = "127.0.0.1"
MQTT_PORT   = 1883
MQTT_USER   = "your_mqtt_user"
MQTT_PASS   = "your_mqtt_password"
SENSOR_IDS  = [44]               # whitelist: only your own sensor IDs

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.connect(MQTT_HOST, MQTT_PORT)
client.loop_start()

ser = serial.Serial(SERIAL_PORT, BAUD, timeout=30)
logging.info("Connected to JeeLink on %s", SERIAL_PORT)

while True:
    try:
        line = ser.readline().decode("utf-8", errors="ignore").strip()

        if not line.startswith("OK 9"):
            continue

        parts = line.split()
        if len(parts) < 7:
            continue

        sensor_id = int(parts[2])

        if sensor_id not in SENSOR_IDS:
            continue

        flags     = int(parts[3])
        temp      = (int(parts[4]) * 256 + int(parts[5])) / 10.0 - 100.0
        humidity  = int(parts[6])
        new_batt  = bool(flags & 0x80)
        weak_batt = bool(flags & 0x01)

        logging.info("Sensor %d: %.1f°C, %d%% rH (new_batt=%s weak_batt=%s)",
                     sensor_id, temp, humidity, new_batt, weak_batt)

        topic = f"lacrosse/{sensor_id}"
        client.publish(f"{topic}/temperature", round(temp, 1), retain=True)
        client.publish(f"{topic}/humidity", humidity, retain=True)
        client.publish(f"{topic}/battery_low", int(weak_batt), retain=True)

    except Exception as e:
        logging.error("Error: %s", e)
        time.sleep(5)

The SENSOR_IDS whitelist is important. The 868 MHz band is shared, and you will pick up sensors from neighboring households. Temporarily remove the filter to discover your sensor’s ID - cross-reference it against the physical display or move the sensor around to identify which readings are yours.

The retain=True flag on every publish call is equally important. Without it, Home Assistant will show the entity as unavailable after a restart, because the broker has no stored value to replay to new subscribers.

Running as a Service

Create an rc script at /usr/local/etc/rc.d/lacrosse2mqtt:

#!/bin/sh
# PROVIDE: lacrosse2mqtt
# REQUIRE: NETWORKING
# KEYWORD: shutdown

. /etc/rc.subr

name="lacrosse2mqtt"
rcvar="lacrosse2mqtt_enable"
command="/usr/local/bin/python3"
command_args="/usr/local/bin/lacrosse2mqtt.py"
pidfile="/var/run/${name}.pid"
logfile="/var/log/${name}.log"

start_cmd="${name}_start"

lacrosse2mqtt_start()
{
    echo "Starting ${name}..."
    /usr/sbin/daemon -p ${pidfile} -o ${logfile} \
        ${command} ${command_args}
}

load_rc_config $name
: ${lacrosse2mqtt_enable:=NO}

run_rc_command "$1"

Enable and start it:

chmod +x /usr/local/etc/rc.d/lacrosse2mqtt
echo 'lacrosse2mqtt_enable="YES"' >> /etc/rc.conf
service lacrosse2mqtt start

Verify with service lacrosse2mqtt status and tail -f /var/log/lacrosse2mqtt.log.

Verifying MQTT

Before touching Home Assistant, confirm messages are arriving at the broker:

mosquitto_sub -h 127.0.0.1 -u your_mqtt_user -P your_mqtt_password -t "lacrosse/#" -v
lacrosse/44/temperature 20.7
lacrosse/44/humidity 51
lacrosse/44/battery_low 0

If nothing shows up, check the log file and make sure the MQTT credentials are correct.

Home Assistant Configuration

Add the sensors to configuration.yaml:

mqtt:
  sensor:
    - name: "Living Room Temperature"
      unique_id: "lacrosse_44_temperature"
      state_topic: "lacrosse/44/temperature"
      unit_of_measurement: "°C"
      device_class: temperature
      state_class: measurement

    - name: "Living Room Humidity"
      unique_id: "lacrosse_44_humidity"
      state_topic: "lacrosse/44/humidity"
      unit_of_measurement: "%"
      device_class: humidity
      state_class: measurement

    - name: "Living Room Sensor Battery"
      unique_id: "lacrosse_44_battery"
      state_topic: "lacrosse/44/battery_low"
      device_class: battery

Check the configuration under Developer Tools, restart Home Assistant, and the entities will appear under Settings > Devices & Services > Entities. You can also verify MQTT messages are arriving inside HA at Settings > Devices & Services > MQTT > Configure using the “Listen to a topic” field with lacrosse/#.

Adding More Sensors

When you add a new LaCrosse sensor:

  1. Insert fresh batteries and let it transmit for a minute
  2. Watch the log (tail -f /var/log/lacrosse2mqtt.log) for the new sensor ID
  3. Add the ID to SENSOR_IDS in the Python script
  4. Restart the service (service lacrosse2mqtt restart)
  5. Add the corresponding MQTT sensors to configuration.yaml

Troubleshooting

Garbage output from the serial port: Wrong baud rate. The JeeLink with LaCrosseITPlusReader uses 57600. Verify interactively with cu before anything else.

Sensor IDs from neighbors: Expected on the shared 868 MHz band. Use the SENSOR_IDS whitelist.

new_batt=True after battery change: Normal. The sensor sets this flag temporarily after a battery insertion. It clears on its own.

Entity shows “—” in Home Assistant: MQTT messages are not being retained. Check that retain=True is set in all client.publish() calls.

MQTT connection refused: Your broker likely requires authentication. Add credentials via client.username_pw_set() in the script.

Conclusion

Component Value
Serial device /dev/cuaU1
Baud rate 57600
JeeLink sketch LaCrosseITPlusReader 10.1s
MQTT topic structure lacrosse/<sensor_id>/temperature etc.
Temperature formula (high * 256 + low) / 10.0 - 100.0
Humidity Direct byte value

The pattern here - USB device in a FreeBSD jail, data bridged over MQTT - generalizes well beyond LaCrosse sensors. Any USB-based RF receiver that can’t be passed directly into a bhyve VM can be handled this way. If you’re already running zigbee2mqtt in a jail, adding another serial device is just a few lines in your devfs rules and a small Python script away.


Further Reading

Comments

You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.

Search for the copied link on your Mastodon instance to reply.

Loading comments...