TFA Dostmann (La Crosse) 30.32XX temperature humidity outdoor sensor - rc_switch

Introduction

Within a bigger project I wanted to capture the outdoor temperature, too. An installed outdoor sensor belonging to a simple weather station from TFA Dostmann (some are identical to La Crosse outdoor sensors) already transmits the information at a frequency of 433 MHz.
To make things inexpensive and easy in and yet in a nice case I bought a “Sonoff RF-Bridge”. Unfortunately it was the bridge in version V2 with a currently not hacked RF-preprocessor. So I decided to bypass the preprocessor like described here. At the end this is a standard ESP8266 receiving a simple RF decoded bit stream at a GPIO-pin. Therefore my basic ESPHOME recipe is valid for every ESP combined with an inexpensive 433 MHz receiver unit.

My “rfgateway” receives data from the 433 Mhz outdoor sensor:

  • temperature
  • humidity
  • battery state
  • received sensor addresses for diagnostics

Within homeassistant you can configure:

  • the sensor address
  • the sensor channel (currently I support only one channel, since my outdoor sensor provides only one, but it is easy to expand to more channels)

Implementation

My solution consists of 5 files, the ESPHOME yaml-file and 4 C++ extension files. If you like to use my recipe put all files in the same directory. BTW: I used esphome at the command line. Esphome was installed with pipenv.

The rfgateway.yaml

esphome:
  name: "rfgateway"
  friendly_name: "RF-Gateway"
  comment: "KlaHi Sensor TFA 30.3206.02, many credits to emax73 https://github.com/emax73/TFA-Spring-sensor-30.3206.02"
  project:
    name: "KlaHi.rfgateway"
    version: "1.0.0"
  platform: ESP8266
  board: esp8285
  includes:
    - tfa_30_32xx.h
    - bit_util.h
    - tfa_30_32xx.cpp
    - bit_util.cpp

packages:
  ota: !include common/ota.yaml
  wifi: !include common/wifi.yaml
  
substitutions:
  TIMEOUT: 180s

api:

#web_server:
captive_portal:

logger: 

status_led:
  pin:
    number: GPIO13
    inverted: yes
    
number:
  - platform: template
    name: Sensor_Address
    id: SensorAddress1
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: 177
    min_value: 0
    max_value: 255
    step: 1
  - platform: template
    name: Sensor_Channel
    id: SensorChannel1
    entity_category: config
    optimistic: true
    restore_value: true
    initial_value: 0
    min_value: 0
    max_value: 3
    step: 1

globals:
  - id: temp
    type: "float"
    initial_value: "NAN"
  - id: hum 
    type: "float"
    initial_value: "NAN"
  - id: bat
    type: "bool"
    initial_value: "true"
  - id: adr 
    type: "uint8_t"
    initial_value: "177"
    
# The GPIO assignment applies only to Sonoff rf-bridge-V2 with modifications 
# Please view https://github.com/arendst/Tasmota/discussions/13283
# and here https://community.home-assistant.io/t/new-sonoff-rf-bridge-board-need-flashing-help/344326/17
# USBRX = GPIO4 ---> receiver
# receiver = pin 5 of the 8-legged chip (the one closer to the wifi antenna) 
# SYN470R or Wisesun WS490
remote_receiver:
  pin: GPIO04
  # dump: rc_switch
  tolerance: # signal receive is very sensitive to this tolerance!
    type: percentage 
    value: 55% # I don't know why?
  filter: 100us # rc-switch differs heavily from result, greater 200us no data ??
  idle: 4ms # sync is about 4 * 1.7 ms
  
  on_rc_switch:
    - sensor.template.publish:
        id: t1
        state: !lambda |-
          { 
            TFA_30_32XX tfa = TFA_30_32XX(x.code);
          
            // ESP_LOGD("on_rc_switch: ", "protocol %d, code 0x%016llx, checksum %d, computed_checksum %d", x.protocol, x.code, tfa.get_checksum(), tfa.get_computed_checksum());
                
            if ((x.protocol == 3) && (tfa.get_checksum() == tfa.get_computed_checksum()))
            {
              ESP_LOGI("on_rc_switch: ", "code 0x%016llx, checksum %d, address %d, channel %d", x.code, tfa.get_checksum(), tfa.get_address(), tfa.get_channel());
              
              id(adr) = tfa.get_address();
              if ((id(adr) == id(SensorAddress1).state) && (tfa.get_channel() == id(SensorChannel1).state))
              {
                id(temp) = tfa.get_temperature();
                id(hum)  = tfa.get_humidity();
                id(bat)  = tfa.get_battery_state();
              }
            }
            
            return id(temp);
          };

    - sensor.template.publish:
        id: h1
        state: !lambda |-
          { 
            return id(hum);
          };
          
    - binary_sensor.template.publish:
        id: b1
        state: !lambda |-
          { 
            return id(bat);
          };
          
    - sensor.template.publish:
        id: Addresses
        state: !lambda |-
          { 
            return id(adr);
          };
          
sensor:
  - platform: template
    name: "T1"
    id: t1
    unit_of_measurement: "°C"
    device_class: "temperature"
    state_class: "measurement"
    accuracy_decimals: 1
    filters:
      - timeout: $TIMEOUT
      - delta: "0.05"
  - platform: template
    name: "H1"
    id: h1
    unit_of_measurement: "%"
    device_class: "humidity"
    state_class: "measurement"
    accuracy_decimals: 0
    filters:
      - timeout: $TIMEOUT
      - delta: "0.5"
  - platform: template
    name: "Addresses"
    id: Addresses
    entity_category: diagnostic
    accuracy_decimals: 0
    filters:
      - timeout: $TIMEOUT
      - delta: "0.5"
      
binary_sensor:
  - platform: template
    name: "B1"
    id: b1
    entity_category: diagnostic    


# The GPIO assignment applies only to Sonoff rf-bridge V2 with modifications
# Please view https://github.com/arendst/Tasmota/discussions/13283
# and here https://community.home-assistant.io/t/new-sonoff-rf-bridge-board-need-flashing-help/344326/17
# USBTX = GPIO5 ---> trasmitter
# transmitter = pin 4 of the 6-legged chip (closest to r12)
# SYN115 ASK transmitter

#remote_transmitter:
#  pin: GPIO05
#  carrier_duty_percent: 100%

# this will log received commands, and can also transmit. Read up here:
# https://esphome.io/components/remote_transmitter.html#remote-setting-up-rf 

The tfa_30_32xx.h (C++ header file), contains extractor methods

#pragma once

/*
 * Credentials to https://github.com/merbanan/rtl_433/blob/e4546052897906d27aa070d3edc33294399473e7/src/devices/tfa_30_3221.c 
 * and to https://github.com/emax73/TFA-Spring-sensor-30.3206.02/tree/master
 *
 * KlaHi, 17.12.2024
 */

class TFA_30_32XX
{
public:
    TFA_30_32XX(uint64_t code);
    
    uint8_t get_address(); 
    uint8_t get_checksum();
    uint8_t get_channel();
    float   get_temperature();
    float   get_humidity();
    bool    get_battery_state();
    uint8_t get_computed_checksum();
    
private:
    uint64_t code_;
};

The tfa_30_32xx.cpp (C++ implementation file)

/*
 * Credentials to https://github.com/merbanan/rtl_433/blob/e4546052897906d27aa070d3edc33294399473e7/src/devices/tfa_30_3221.c 
 * and to https://github.com/emax73/TFA-Spring-sensor-30.3206.02/tree/master
 *
 * KlaHi, 17.12.2024
 */

/**
    Temperature/Humidity outdoor sensor TFA 30.3221.02.

    This is the same as LaCrosse-TX141THBv2 and should be merged.

    S.a. https://github.com/RFD-FHEM/RFFHEM/blob/21fca327d84b7cd1a9cf9743e42d647cb614b4da/FHEM/14_SD_WS.pm

        0    4    | 8    12   | 16   20   | 24   28   | 32   36
        --------- | --------- | --------- | --------- | ---------
        0000 1001 | 0001 0110 | 0001 0000 | 0000 0111 | 0100 1001
        IIII IIII | BSCC TTTT | TTTT TTTT | HHHH HHHH | XXXX XXXX

    - I:  8 bit random id (changes on power-loss)
    - B:  1 bit battery indicator (0=>OK, 1=>LOW)
    - S:  1 bit sendmode (0=>auto, 1=>manual)
    - C:  2 bit channel valid channels are 0-2 (1-3)
    - T: 12 bit unsigned temperature, offset 500, scaled by 10
    - H:  8 bit relative humidity percentage
    - X:  8 bit checksum digest 0x31, 0xf4

    The sensor sends 3 repetitions at intervals of about 60 seconds.

    Caution: x.code contains the 4 sync pulses in advance 
*/

#include <cinttypes>
#include "tfa_30_32xx.h"
#include "bit_util.h"

TFA_30_32XX::TFA_30_32XX(uint64_t code)
{
    this->code_ = code;
}

uint8_t TFA_30_32XX::get_address() 
{
    return static_cast<uint8_t> ((this->code_ >> 52) & 0xff);;
}

uint8_t TFA_30_32XX::get_checksum()
{
    return static_cast<uint8_t>((this->code_ >> 20) & 0xff);
}

uint8_t TFA_30_32XX::get_channel()
{
    return static_cast<uint8_t>((this->code_ >> 48) & 0x03);
}

float TFA_30_32XX::get_temperature()
{
    return static_cast<float>(static_cast<int>((this->code_ >> 36) & 0xfff) -500) / 10;
}

float TFA_30_32XX::get_humidity()
{
    return static_cast<float>((this->code_ >> 28) & 0xff);
}

bool TFA_30_32XX::get_battery_state()
{
    return not static_cast<bool>((this->code_ >> 51) & 1);
}

uint8_t TFA_30_32XX::get_computed_checksum()
{
    uint8_t b[4];
    uint32_t t = uint32_t((this->code_ >> 28) & 0xffffffff);
    
    b[0] = static_cast<uint8_t>(t >> 24);
    b[1] = static_cast<uint8_t>(t >> 16);
    b[2] = static_cast<uint8_t>(t >> 8);
    b[3] = static_cast<uint8_t>(t);

    return lfsr_digest8_reflect(b, 4, 0x31, 0xf4);
}

The bit_util.h (C++ header file)

#pragma once

/*
 * Credentials to https://github.com/merbanan/rtl_433/blob/e4546052897906d27aa070d3edc33294399473e7/src/bit_util.c 
 * 
 * KlaHi, 17.12.2024
 */

uint8_t lfsr_digest8_reflect(uint8_t const message[], int bytes, uint8_t gen, uint8_t key);

the bit_util.cpp (C++ implementation file)

/*
 * Credentials to https://github.com/merbanan/rtl_433/blob/e4546052897906d27aa070d3edc33294399473e7/src/bit_util.c 
 * 
 * KlaHi, 17.12.2024
 */
 
#include <cinttypes>
#include "bit_util.h"

uint8_t lfsr_digest8_reflect(uint8_t const message[], int bytes, uint8_t gen, uint8_t key)
{
    uint8_t sum = 0;
    // Process message from last byte to first byte (reflected)
    for (int k = bytes - 1; k >= 0; --k) {
        uint8_t data = message[k];
        // Process individual bits of each byte (reflected)
        for (int i = 0; i < 8; ++i) {
            // fprintf(stderr, "key at %d.%d : %02x\n", k, i, key);
            // XOR key into sum if data bit is set
            if ((data >> i) & 1) {
                sum ^= key;
            }

            // roll the key left (actually the msb is dropped here)
            // and apply the gen (needs to include the dropped msb as lsb)
            if (key & 0x80)
                key = (key << 1) ^ gen;
            else
                key = (key << 1);
        }
    }
    return sum;
} 

Strange things

I used the remote_receiver component and there the rc_switch filter. The rc_switch filter seems strongly related to the original RC_switch project. In contrast to the original RC-switch the filter implementation does not allow to configure a custom protocol. So I only could use the built-in 9 protocols. But I needed totally different timings RCSwitchBase(820, 880, 220, 480, 480, 220, false) . All numbers are the expected high and low pulse widths in usec for sync, zero, one (see here). Since this protocol can’t be configured I had to use an extremely large timing tolerance of 55%. Fortunately a totally different protocol deciphered the expected bit stream. I suggest to expand the rc-switch setup to configure custom protocols like here. Currently it seems to complicated for me to adapt it myself.

Postscript

Please respond if you have any suggestion or improvement or only if you like my little project.