In the last post a Raspberry Pi tricked a Vevor weather station into uploading to it, and weewx has been recording the outdoor readings since. The Vevor console sends nothing about indoors, though. Meanwhile an rtl_433 box in the house already receives a Fine Offset WH25 indoor sensor and publishes its temperature, humidity and pressure to the house MQTT broker, one value per topic:

rtl_433/<host>/devices/Fineoffset-WH25/170/temperature_C  => 24.3
rtl_433/<host>/devices/Fineoffset-WH25/170/humidity       => 46
rtl_433/<host>/devices/Fineoffset-WH25/170/pressure_hPa   => 1010.1

I wanted those three numbers in the same weewx database as the outdoor data, so the reports show inTemp, inHumidity and station pressure next to everything else.

The catch: weewx loads exactly one station driver. station_type names it, and that driver’s genLoopPackets() is the only source of loop packets. The interceptor is already that driver.

MetaDriver

Tom Keffer, who writes weewx, published weewx-metadriver. It is a driver whose job is to run other drivers. You list the station types you want, it loads each one with the normal loader() call, runs each on its own thread, and merges their loop packets into one queue that the engine reads from. The first driver in the list is the primary: its archive records and clock functions are the ones weewx uses. The others only contribute loop packets. Every packet gets a source key naming the child it came from.

Installation is the usual extension install:

~/weewx-venv/bin/python ~/weewx/src/weectl.py extension install --yes \
  https://github.com/tkeffer/weewx-metadriver/archive/refs/heads/master.zip

I skipped the suggested weectl station reconfigure and edited weewx.conf by hand, so nothing else in the file moved:

[Station]
    station_type = MetaDriver

[MetaDriver]
    # The first entry is the primary station.
    station_types = Interceptor, wxMesh
    driver = user.metadriver

The [Interceptor] stanza stays exactly as it was. record_generation = software in [StdArchive] stays too. That’s what makes this work: with software record generation, weewx builds each archive record by accumulating every loop packet from the interval, whichever child it came from. The WH25 values and the Vevor values land in the same record.

Two things I checked before trusting it. The string source key goes through the accumulator like any other field, and weewx 5.5’s scalar accumulator quietly converts non-numeric values to None rather than crashing. And [StdConvert] target_unit = US converts each loop packet before it is accumulated, so one child sending metric and the other sending US units is fine.

An MQTT driver from 2016

For the MQTT side I used weewxMQTT, a small driver called wxMesh that subscribes to a topic and turns messages into loop packets. It is from the weewx 3 and 4 era and hasn’t been updated, which showed up in several ways:

  • The installer doesn’t run. install.py imports setup, which weewx 5 no longer provides, and its config block isn’t valid Python. So I copied bin/user/wxMesh.py into ~/weewx-data/bin/user/ by hand and wrote the stanza myself.
  • paho-mqtt 2.x rejects the old constructor. pip install paho-mqtt gives you 2.1.0 today, and mqtt.Client(client_id=...) raises ValueError: Unsupported callback API version unless you pass CallbackAPIVersion.VERSION1 first.
  • A broker outage at start-up kills it for good. The driver called client.connect() in __init__, which raises if the broker is down. MetaDriver logs a failed secondary child and drops it. It never retries.
  • No dateTime unless the message carries one. weewx services index packets by dateTime; a packet without it crashes them.
  • Wrong message format for rtl_433. The driver wants one message with a payload like temp:24.3,humidity:46. rtl_433 publishes a bare number per topic. Feeding it 24.3 makes datum.split(":") fail to unpack, which ends the generator, which MetaDriver treats as a dead child.

The last point is the interesting one. In the original design the payload’s keys name the observations. With rtl_433 the topic is the key.

The rewrite

I kept the driver’s shape and rewrote the internals. The upstream file stays next to it as wxMesh.py.orig. The parts that matter:

kwargs = dict(client_id=self.client_id, clean_session=False,
              protocol=mqtt.MQTTv311, transport="tcp")
if hasattr(mqtt, 'CallbackAPIVersion'):      # paho-mqtt >= 2.0
    self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, **kwargs)
else:
    self.client = mqtt.Client(**kwargs)

self.client.reconnect_delay_set(min_delay=1, max_delay=60)
self.client.connect_async(self.host, self.port, 60)   # never raises here
self.client.loop_start()

connect_async() hands the connection to paho’s network thread, which retries with back-off if the broker isn’t there. Subscribing happens in on_connect, so a reconnect re-subscribes too.

Each message is parsed one of two ways. If the payload contains a colon it is the classic key:value,key:value form. Otherwise the topic is the key and the payload is the value:

def _parse(self, topic, payload):
    data = {}
    if ':' in payload:
        for datum in payload.split(","):
            key, sep, value = datum.partition(":")
            if sep:
                data[key.strip()] = value.strip()
    else:
        data[topic] = payload
    return data

Turning that into a packet uses the label_map from the config. A key is kept if the map knows it, or if it already is a weewx observation name. Anything else is dropped. That matters with a wildcard subscription, because rtl_433 also publishes battery_ok, mic, id and a time string under the same prefix, and none of those belong in a loop packet.

def _to_packet(self, data):
    packet = {'usUnits': self.units}
    for key, value in data.items():
        if key in self.label_map:
            name = self.label_map[key]
        elif key in weewx.units.obs_group_dict:
            name = key
        else:
            continue
        try:
            packet[name] = float(value)
        except ValueError:
            pass
    if not [k for k in packet if k not in ('usUnits', 'dateTime')]:
        return None
    ts = packet.get('dateTime')
    packet['dateTime'] = int(ts) if ts else int(time.time() + 0.5)
    return packet

A packet with only one field is completely normal for weewx. The accumulator takes whatever each packet has. So each rtl_433 message becomes its own tiny packet, and the archive record at the end of the interval has all three values.

The rest of the changes are small: a units option instead of a hard-coded unit system, a port option, the configured client id actually being used, topic accepting a list, and logging through weewx’s logger so the messages show up under journalctl -u weewx with the rest.

The stanza

[wxMesh]
    driver = user.wxMesh
    host = <broker-address>
    port = 1883
    username = <user>
    password = <password>
    client = weewx-pi3-1
    # Quote these. An unquoted # starts a comment in weewx.conf.
    topic = "rtl_433/<host>/devices/Fineoffset-WH25/170/#", "weewx/loop"
    units = METRIC          # rtl_433 sends degC and hPa
    poll_interval = 2
    [[label_map]]
        TIME = dateTime
        rtl_433/<host>/devices/Fineoffset-WH25/170/temperature_C = inTemp
        rtl_433/<host>/devices/Fineoffset-WH25/170/humidity = inHumidity
        rtl_433/<host>/devices/Fineoffset-WH25/170/pressure_hPa = pressure

The weewx/loop topic isn’t used by anything yet. It’s there so a Home Assistant automation can publish a TIME:0,extraTemp1:21.5,... payload later without touching the driver again.

pressure is station pressure, which is what the WH25 measures. barometer still comes from the Vevor. [StdWXCalculate] has both set to prefer_hardware, so weewx uses the measured value when a packet has one and computes it otherwise.

The # that wasn’t a wildcard

The first restart looked fine in the log, until I read the subscription line:

INFO user.wxMesh: wxMesh: subscribed to rtl_433/<host>/devices/Fineoffset-WH25/170/

The trailing # was gone. weewx.conf is a ConfigObj file, and in ConfigObj an unquoted # starts an inline comment. The driver had subscribed to a topic that nothing publishes to. Quoting the values in the topic list fixed it. It’s an easy mistake to make with MQTT, where # shows up in almost every subscription.

Checking

journalctl -u weewx | grep wxMesh
INFO user.wxMesh: wxMesh 0.1-pi3-1: broker <broker-address>:1883, client id weewx-pi3-1, ...
INFO user.wxMesh: wxMesh: connected to <broker-address>:1883 with result code 0
INFO user.wxMesh: wxMesh: subscribed to rtl_433/<host>/devices/Fineoffset-WH25/170/#
INFO user.wxMesh: wxMesh: subscribed to weewx/loop

Then the next archive record, from the venv’s sqlite3 module:

20:00:00  inTemp=75.68  inHumidity=46.0  pressure=29.8185  barometer=29.93  outTemp=67.25
19:55:00  inTemp=None   inHumidity=None  pressure=29.8142  barometer=29.93  outTemp=67.4

24.3 °C in, 75.68 °F out, in the same row as the Vevor’s outdoor temperature.

Gotchas, collected

  • Quote topics containing # in weewx.conf, or the wildcard is eaten as a comment.
  • MetaDriver never restarts a failed child. Make sure the secondary driver can’t raise during construction. connect_async() instead of connect() is the whole fix for MQTT.
  • A wildcard subscription needs a whitelist. Map the fields you want and drop the rest, or battery_ok, mic and friends end up in the packets.
  • paho-mqtt 2.x needs CallbackAPIVersion. Any weewx MQTT driver written before 2024 will hit this.
  • Restarts log a warning from MetaDriver: “worker thread for driver ‘Interceptor’ did not close within 5.0 seconds”. Each child’s generator blocks until its next packet, so the stop takes a little longer. The threads are daemons and the process exits anyway.
  • Stay with record_generation = software. It’s what lets packets from two drivers share one archive record.

The Pi now records an outdoor station that thinks it’s talking to Weather Underground and an indoor sensor that arrives over MQTT from a different computer, in one weewx, with one archive.