I bought a Vevor weather station. It’s a rebadged Fine Offset unit, and its wifi console (firmware 1.0.1) can send readings to exactly two places: Weather Underground and WeatherCloud. The setup page has no custom server field and no port field. You get those two cloud services or nothing.

I wanted the data on my own hardware, recorded by weewx, and I didn’t want an inexpensive IoT console on my LAN or talking to the internet at all.

The end state: a Raspberry Pi 3 (pi3-1) runs its own WPA2 access point just for the station. On that network the Pi’s DNS server says it is wunderground.com. The station uploads to the Pi, thinking it’s Weather Underground, and weewx-interceptor parses the upload and hands it to weewx. The station can’t reach the LAN or the internet, and the weewx reports are served to the LAN by nginx.

How it fits together

Vevor console --wifi (WPA2, SSID weewx-ap)--> Pi wlan0 192.168.5.1
  DHCP + DNS: dnsmasq
    wunderground.com -> 192.168.5.1
    every other name -> NXDOMAIN, nothing forwarded upstream
  HTTP to port 80 --nftables redirect--> :8090 weewx-interceptor (wu-client)
    --> weewxd --> SQLite archive
  weewx reports --> /var/www/weewx --> nginx on port 80 (eth0, LAN)
  IP forwarding off + nftables drops all forwarding to/from wlan0

The station sends a Weather Underground protocol request, a plain HTTP GET to /weatherstation/updateweatherstation.php?ID=...&tempf=...&humidity=..., and the interceptor’s wu-client mode already understands that format. Getting the station to send that request to the Pi only takes a DNS lie and a port redirect.

Hardware and host

  • Raspberry Pi 3B, Raspberry Pi OS based on Debian 13 (trixie), wired to the LAN on eth0
  • The Pi 3’s built-in BCM43430 wifi (brcmfmac driver, 2.4 GHz only) as the access point on wlan0
  • Vevor weather station console with a BL602 wifi chip

The Pi already runs another service that uses port 8080, which is why the interceptor listens on 8090 here.

1. Base system

apt-get update && apt-get install -y git hostapd dnsmasq nginx
timedatectl set-timezone America/Los_Angeles
raspi-config nonint do_wifi_country US
rfkill unblock wifi

2. weewx and the interceptor

I installed weewx 5.5 with the git method, in a Python venv. Do this as your normal user, not root. weectl station create writes the current user and the venv’s Python path into the systemd unit it generates.

python3 -m venv ~/weewx-venv
source ~/weewx-venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install CT3 configobj Pillow ephem
git clone https://github.com/weewx/weewx ~/weewx

python3 ~/weewx/src/weectl.py station create --no-prompt \
  --driver=weewx.drivers.simulator --location="Rocklin, CA" \
  --latitude=<lat> --longitude=<lon> --altitude=<alt>,foot --units=us

python3 ~/weewx/src/weectl.py extension install --yes \
  https://github.com/matthewwall/weewx-interceptor/archive/master.zip

The simulator driver is only there so station create has something to configure. It gets replaced in the next step. Then, as root, install the service:

sh ~<user>/weewx-data/scripts/setup-daemon.sh

3. weewx.conf

The relevant changes in ~/weewx-data/weewx.conf:

[Station]
    station_type = Interceptor

[Interceptor]
    driver = user.interceptor
    device_type = wu-client
    port = 8090

[StdReport]
    HTML_ROOT = /var/www/weewx

[StdArchive]
    archive_interval = 300
    record_generation = software

Two notes:

  • record_generation must be software. The interceptor has no hardware archive memory to read records from. With hardware, weewx starts up and logs loop packets, but it never saves an archive record.
  • HTML_ROOT points outside the home directory, at a directory the weewx user owns. Home directories on this Pi are mode 700, so nginx can’t read the default ~/weewx-data/public_html. The smartphone and mobile sub-reports need their HTML_ROOT moved under /var/www/weewx too.

4. Patching the interceptor

Version 0.60 of the interceptor needed four small fixes for this station, all in ~/weewx-data/bin/user/interceptor.py. Keep a copy of the original, because reinstalling the extension overwrites your patches.

@@ do_POST
-            data = str(self.rfile.read(length))
+            data = _bytes_to_str(self.rfile.read(length))

@@ WUClient LABEL_MAP
             'solarradiation': 'solar_radiation',
+            'solarRadiation': 'solar_radiation',

@@ WUClient IGNORED_LABELS
+            'rainin',

@@ WUClient.Parser.parse
-                pkt['dateTime'] = self.decode_datetime(
-                    data.pop('dateutc', int(time.time() + 0.5)))
+                # Console clock is 1 h fast (no DST) and has no NTP: use the Pi clock
+                data.pop('dateutc', None)
+                pkt['dateTime'] = int(time.time() + 0.5)

What each one fixes:

  1. Python 3 bytes bug. str() on a bytes object gives you the string "b'...'", not the decoded body. The parsed values then fail with could not convert string to float. The module already has a _bytes_to_str helper. It just wasn’t used here.
  2. camelCase label. The Vevor sends solarRadiation where the interceptor expects solarradiation. Without the extra mapping, the log fills with “unrecognized parameter” warnings and solar radiation is never recorded.
  3. Rain. The station sends both rainin (rain in the last hour) and dailyrainin. weewx works out rain per interval from the running daily total, so rainin is ignored to avoid counting the same rain twice.
  4. Timestamps. The console’s clock was an hour ahead. It doesn’t handle daylight saving time, and on an isolated network it can’t reach an NTP server to correct itself. Records were being stamped an hour in the future. The Pi’s clock is correct, so the patch ignores dateutc and stamps each packet when it arrives.

5. The access point: hostapd, not NetworkManager

This part took the most time.

Raspberry Pi OS uses NetworkManager, and NetworkManager can run a wifi hotspot. An open hotspot worked fine. A WPA2 hotspot never completed a handshake with any client, not just the weather station. A kernel and firmware upgrade didn’t change that. Switching to plain hostapd got WPA2 working.

First, tell NetworkManager to leave wlan0 alone (/etc/NetworkManager/conf.d/99-weewx-ap.conf):

[keyfile]
unmanaged-devices=interface-name:wlan0

Then /etc/hostapd/hostapd.conf:

interface=wlan0
driver=nl80211
ssid=weewx-ap
country_code=US
ieee80211d=1
hw_mode=g
channel=6
ieee80211n=1
wmm_enabled=1
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
wpa=2
wpa_key_mgmt=WPA-PSK
wpa_pairwise=CCMP
rsn_pairwise=CCMP
ieee80211w=0
wpa_passphrase=<your passphrase>
eapol_version=1
wpa_pairwise_update_count=10

The slow handshake

Even with hostapd and the default settings, the weather station failed to join about nine times out of ten. With debug logging on, hostapd showed the reason:

WPA: PTKSTART: Retry limit 4 reached

The station’s BL602 wifi chip is slow to answer the WPA2 4-way handshake. hostapd sends message 1, waits, resends, and gives up before the station replies. The last two lines of the config fix it:

  • wpa_pairwise_update_count=10 lets hostapd resend handshake messages up to 10 times instead of 4, so a slow client has time to answer.
  • eapol_version=1 makes hostapd use the older EAPOL version, which some embedded wifi stacks handle better.

After that change, the station joined on the first try every time. If you turned up hostapd’s logging to debug this (logger_syslog_level=0), turn it back down afterwards.

6. DHCP and the DNS lie

/etc/dnsmasq.d/weewx-ap.conf:

interface=wlan0
bind-interfaces
except-interface=lo
dhcp-range=192.168.5.10,192.168.5.254,1h
dhcp-option=option:router,192.168.5.1
dhcp-option=option:dns-server,192.168.5.1

# Never forward upstream. Only wunderground.com resolves, to this Pi.
no-resolv
no-poll
address=/wunderground.com/192.168.5.1
address=/#/

address=/wunderground.com/... covers the domain and all its subdomains, so rtupdate.wunderground.com and weatherstation.wunderground.com both resolve to the Pi. address=/#/ with no address answers every other name with NXDOMAIN. no-resolv means dnsmasq never asks an upstream server, so the station can’t learn a real address for anything.

I used 192.168.5.0/24 for the AP subnet because the console’s own setup hotspot uses 192.168.4.1, and I didn’t want the two to overlap.

7. Redirect and isolation firewall

A small script sets the address on wlan0 and loads an nftables table, at /usr/local/sbin/weewx-ap-net (mode 755):

#!/bin/sh
set -e
ip link set wlan0 up
ip addr replace 192.168.5.1/24 dev wlan0
nft delete table inet weewx 2>/dev/null || true
nft -f - <<'NFT'
table inet weewx {
	chain prerouting {
		type nat hook prerouting priority dstnat; policy accept;
		iifname "wlan0" tcp dport 80 counter redirect to :8090
		iifname "wlan0" tcp dport 443 counter
	}
	chain input {
		type filter hook input priority filter; policy accept;
		iifname != "wlan0" accept
		ct state established,related accept
		udp dport 67 accept
		meta l4proto { tcp, udp } th dport 53 accept
		tcp dport 8090 accept
		icmp type echo-request accept
		counter drop
	}
	chain forward {
		type filter hook forward priority filter; policy accept;
		iifname "wlan0" counter drop
		oifname "wlan0" counter drop
	}
}
NFT
  • prerouting sends the station’s HTTP requests to the interceptor. The port 443 rule only counts packets, so you can see whether the station ever tries HTTPS.
  • input lets wifi clients reach DHCP, DNS, the interceptor and ping, and nothing else on the Pi. SSH and nginx can’t be reached from wlan0.
  • forward drops all traffic to or from wlan0. IP forwarding is also left off (no net.ipv4.ip_forward=1, no masquerade), so the isolation doesn’t depend on a single setting.

A oneshot unit runs the script at boot (/etc/systemd/system/weewx-ap-net.service):

[Unit]
Description=weewx AP: wlan0 address, port redirect and isolation firewall
Before=hostapd.service dnsmasq.service
After=NetworkManager.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/weewx-ap-net

[Install]
WantedBy=multi-user.target

hostapd and dnsmasq each get a drop-in (/etc/systemd/system/hostapd.service.d/weewx-ap.conf and the same path for dnsmasq.service.d):

[Unit]
After=weewx-ap-net.service
Wants=weewx-ap-net.service

Use Wants=, not Requires=. With Requires=, restarting weewx-ap-net to reload the firewall also restarts hostapd, and the station is kicked off the network.

systemctl daemon-reload
systemctl unmask hostapd
systemctl enable --now weewx-ap-net hostapd dnsmasq

8. Serving the reports

/etc/nginx/sites-available/weewx:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    root /var/www/weewx;
    index index.html;
    location / { try_files $uri $uri/ =404; }
}
rm /etc/nginx/sites-enabled/default
ln -s /etc/nginx/sites-available/weewx /etc/nginx/sites-enabled/weewx
systemctl reload nginx

LAN clients get the weewx Seasons report at http://<pi-address>/. Devices on the station’s wifi can’t load it, because port 80 on wlan0 goes to the interceptor. That’s fine, since the station is the only thing on that network.

9. Pointing the console at it

On the Vevor console’s setup page, join the weewx-ap network and enable Weather Underground with any station ID and key (1234 works, since the interceptor doesn’t check them). WeatherCloud can stay enabled. Its DNS lookups get NXDOMAIN and it quietly fails.

Checking that it works

iw dev wlan0 station dump                 # station is associated
cat /var/lib/misc/dnsmasq.leases          # and got a 192.168.5.x lease
journalctl -u hostapd | grep "handshake completed"
nft list table inet weewx                 # port-80 counter rising, drop counters at 0
journalctl -u weewx -f                    # "Added record ..." every 5 minutes
curl http://<pi-address>/                 # weewx Seasons page

In nft list table inet weewx, the port 80 redirect counter should go up with every upload. The forward drop counters should stay at zero. If they start climbing, the station is trying to reach something other than “Weather Underground”.

Gotchas, collected

  • NetworkManager’s WPA2 hotspot on the Pi 3 (brcmfmac) never completed a handshake. An open hotspot worked. hostapd fixed it.
  • The BL602 chip answers the handshake slowly. Set wpa_pairwise_update_count=10 and eapol_version=1, or expect “Retry limit 4 reached”.
  • The interceptor’s POST handling has a Python 3 bug, which shows up as could not convert string to float.
  • record_generation = hardware saves no records with the interceptor. Use software.
  • The console clock is an hour fast and can’t sync on an isolated network, so use the Pi’s clock for timestamps.
  • Don’t overlap the console’s setup subnet (192.168.4.x).
  • sqlite3 isn’t installed by default. Query the archive with the venv’s Python sqlite3 module instead of installing the CLI.
  • Run git in ~/weewx as the weewx user. As root, git refuses with a “dubious ownership” error.

The station now posts its “Weather Underground” updates to a Pi in the same house. The station has no idea, and Weather Underground never hears from it.