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 (
brcmfmacdriver, 2.4 GHz only) as the access point onwlan0 - 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_generationmust besoftware. The interceptor has no hardware archive memory to read records from. Withhardware, weewx starts up and logs loop packets, but it never saves an archive record.HTML_ROOTpoints 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 theirHTML_ROOTmoved under/var/www/weewxtoo.
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:
- Python 3 bytes bug.
str()on abytesobject gives you the string"b'...'", not the decoded body. The parsed values then fail withcould not convert string to float. The module already has a_bytes_to_strhelper. It just wasn’t used here. - camelCase label. The Vevor sends
solarRadiationwhere the interceptor expectssolarradiation. Without the extra mapping, the log fills with “unrecognized parameter” warnings and solar radiation is never recorded. - Rain. The station sends both
rainin(rain in the last hour) anddailyrainin. weewx works out rain per interval from the running daily total, soraininis ignored to avoid counting the same rain twice. - 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
dateutcand 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=10lets hostapd resend handshake messages up to 10 times instead of 4, so a slow client has time to answer.eapol_version=1makes 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 (nonet.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=10andeapol_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 = hardwaresaves no records with the interceptor. Usesoftware.- 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).
sqlite3isn’t installed by default. Query the archive with the venv’s Pythonsqlite3module instead of installing the CLI.- Run git in
~/weewxas 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.