<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Weewx on Andy Little</title><link>https://andylittle.net/tags/weewx/</link><description>Recent content in Weewx on Andy Little</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 18 Sep 2026 07:00:00 -0700</lastBuildDate><atom:link href="https://andylittle.net/tags/weewx/index.xml" rel="self" type="application/rss+xml"/><item><title>Two Drivers, One weewx: Adding MQTT Sensors Next to the Weather Station</title><link>https://andylittle.net/blog/2026/weewx-metadriver-mqtt-second-driver/</link><pubDate>Fri, 18 Sep 2026 07:00:00 -0700</pubDate><guid>https://andylittle.net/blog/2026/weewx-metadriver-mqtt-second-driver/</guid><description>weewx runs exactly one station driver. I wanted the Vevor station&amp;rsquo;s data and an indoor sensor that only exists as MQTT messages from rtl_433. weewx-metadriver runs both drivers side by side, and an old MQTT driver needed a rewrite before it would work with weewx 5 and paho-mqtt 2.</description><content:encoded><![CDATA[<p>In the <a href="/blog/2026/weewx-interceptor-isolated-weather-station/">last post</a> a
Raspberry Pi tricked a Vevor weather station into uploading to it, and
<a href="https://weewx.com/">weewx</a> 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:</p>
<pre tabindex="0"><code>rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/temperature_C  =&gt; 24.3
rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/humidity       =&gt; 46
rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/pressure_hPa   =&gt; 1010.1
</code></pre><p>I wanted those three numbers in the same weewx database as the outdoor data,
so the reports show <code>inTemp</code>, <code>inHumidity</code> and station <code>pressure</code> next to
everything else.</p>
<p>The catch: weewx loads exactly one station driver. <code>station_type</code> names it,
and that driver&rsquo;s <code>genLoopPackets()</code> is the only source of loop packets. The
interceptor is already that driver.</p>
<h2 id="metadriver">MetaDriver</h2>
<p>Tom Keffer, who writes weewx, published
<a href="https://github.com/tkeffer/weewx-metadriver">weewx-metadriver</a>. 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 <code>loader()</code> 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 <em>primary</em>: its archive records and
clock functions are the ones weewx uses. The others only contribute loop
packets. Every packet gets a <code>source</code> key naming the child it came from.</p>
<p>Installation is the usual extension install:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">~/weewx-venv/bin/python ~/weewx/src/weectl.py extension install --yes <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://github.com/tkeffer/weewx-metadriver/archive/refs/heads/master.zip
</span></span></code></pre></div><p>I skipped the suggested <code>weectl station reconfigure</code> and edited <code>weewx.conf</code>
by hand, so nothing else in the file moved:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Station]</span>
</span></span><span class="line"><span class="cl">    <span class="na">station_type</span> <span class="o">=</span> <span class="s">MetaDriver</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[MetaDriver]</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># The first entry is the primary station.</span>
</span></span><span class="line"><span class="cl">    <span class="na">station_types</span> <span class="o">=</span> <span class="s">Interceptor, wxMesh
</span></span></span><span class="line"><span class="cl"><span class="s">    driver = user.metadriver</span>
</span></span></code></pre></div><p>The <code>[Interceptor]</code> stanza stays exactly as it was. <code>record_generation = software</code> in <code>[StdArchive]</code> stays too. That&rsquo;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.</p>
<p>Two things I checked before trusting it. The string <code>source</code> key goes through
the accumulator like any other field, and weewx 5.5&rsquo;s scalar accumulator
quietly converts non-numeric values to <code>None</code> rather than crashing. And
<code>[StdConvert] target_unit = US</code> converts each loop packet before it is
accumulated, so one child sending metric and the other sending US units is
fine.</p>
<h2 id="an-mqtt-driver-from-2016">An MQTT driver from 2016</h2>
<p>For the MQTT side I used
<a href="https://github.com/morrowwm/weewxMQTT">weewxMQTT</a>, a small driver called
<code>wxMesh</code> that subscribes to a topic and turns messages into loop packets. It
is from the weewx 3 and 4 era and hasn&rsquo;t been updated, which showed up in
several ways:</p>
<ul>
<li><strong>The installer doesn&rsquo;t run.</strong> <code>install.py</code> imports <code>setup</code>, which weewx 5
no longer provides, and its config block isn&rsquo;t valid Python. So I copied
<code>bin/user/wxMesh.py</code> into <code>~/weewx-data/bin/user/</code> by hand and wrote the
stanza myself.</li>
<li><strong>paho-mqtt 2.x rejects the old constructor.</strong> <code>pip install paho-mqtt</code>
gives you 2.1.0 today, and <code>mqtt.Client(client_id=...)</code> raises
<code>ValueError: Unsupported callback API version</code> unless you pass
<code>CallbackAPIVersion.VERSION1</code> first.</li>
<li><strong>A broker outage at start-up kills it for good.</strong> The driver called
<code>client.connect()</code> in <code>__init__</code>, which raises if the broker is down.
MetaDriver logs a failed secondary child and drops it. It never retries.</li>
<li><strong>No <code>dateTime</code> unless the message carries one.</strong> weewx services index
packets by <code>dateTime</code>; a packet without it crashes them.</li>
<li><strong>Wrong message format for rtl_433.</strong> The driver wants one message with a
payload like <code>temp:24.3,humidity:46</code>. rtl_433 publishes a bare number per
topic. Feeding it <code>24.3</code> makes <code>datum.split(&quot;:&quot;)</code> fail to unpack, which
ends the generator, which MetaDriver treats as a dead child.</li>
</ul>
<p>The last point is the interesting one. In the original design the payload&rsquo;s
keys name the observations. With rtl_433 the <em>topic</em> is the key.</p>
<h2 id="the-rewrite">The rewrite</h2>
<p>I kept the driver&rsquo;s shape and rewrote the internals. The upstream file stays
next to it as <code>wxMesh.py.orig</code>. The parts that matter:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">kwargs</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">client_id</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">client_id</span><span class="p">,</span> <span class="n">clean_session</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">protocol</span><span class="o">=</span><span class="n">mqtt</span><span class="o">.</span><span class="n">MQTTv311</span><span class="p">,</span> <span class="n">transport</span><span class="o">=</span><span class="s2">&#34;tcp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">hasattr</span><span class="p">(</span><span class="n">mqtt</span><span class="p">,</span> <span class="s1">&#39;CallbackAPIVersion&#39;</span><span class="p">):</span>      <span class="c1"># paho-mqtt &gt;= 2.0</span>
</span></span><span class="line"><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">client</span> <span class="o">=</span> <span class="n">mqtt</span><span class="o">.</span><span class="n">Client</span><span class="p">(</span><span class="n">mqtt</span><span class="o">.</span><span class="n">CallbackAPIVersion</span><span class="o">.</span><span class="n">VERSION1</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">client</span> <span class="o">=</span> <span class="n">mqtt</span><span class="o">.</span><span class="n">Client</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="bp">self</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">reconnect_delay_set</span><span class="p">(</span><span class="n">min_delay</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">max_delay</span><span class="o">=</span><span class="mi">60</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="bp">self</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">connect_async</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">host</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">port</span><span class="p">,</span> <span class="mi">60</span><span class="p">)</span>   <span class="c1"># never raises here</span>
</span></span><span class="line"><span class="cl"><span class="bp">self</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">loop_start</span><span class="p">()</span>
</span></span></code></pre></div><p><code>connect_async()</code> hands the connection to paho&rsquo;s network thread, which
retries with back-off if the broker isn&rsquo;t there. Subscribing happens in
<code>on_connect</code>, so a reconnect re-subscribes too.</p>
<p>Each message is parsed one of two ways. If the payload contains a colon it is
the classic <code>key:value,key:value</code> form. Otherwise the topic is the key and the
payload is the value:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_parse</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">topic</span><span class="p">,</span> <span class="n">payload</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="s1">&#39;:&#39;</span> <span class="ow">in</span> <span class="n">payload</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">datum</span> <span class="ow">in</span> <span class="n">payload</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;,&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">key</span><span class="p">,</span> <span class="n">sep</span><span class="p">,</span> <span class="n">value</span> <span class="o">=</span> <span class="n">datum</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;:&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">sep</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">data</span><span class="p">[</span><span class="n">key</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span> <span class="o">=</span> <span class="n">value</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">data</span><span class="p">[</span><span class="n">topic</span><span class="p">]</span> <span class="o">=</span> <span class="n">payload</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">data</span>
</span></span></code></pre></div><p>Turning that into a packet uses the <code>label_map</code> 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 <code>battery_ok</code>, <code>mic</code>, <code>id</code> and a <code>time</code> string under the same
prefix, and none of those belong in a loop packet.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_to_packet</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">packet</span> <span class="o">=</span> <span class="p">{</span><span class="s1">&#39;usUnits&#39;</span><span class="p">:</span> <span class="bp">self</span><span class="o">.</span><span class="n">units</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">data</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">label_map</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">name</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">label_map</span><span class="p">[</span><span class="n">key</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">        <span class="k">elif</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">weewx</span><span class="o">.</span><span class="n">units</span><span class="o">.</span><span class="n">obs_group_dict</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">name</span> <span class="o">=</span> <span class="n">key</span>
</span></span><span class="line"><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">packet</span><span class="p">[</span><span class="n">name</span><span class="p">]</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">pass</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="p">[</span><span class="n">k</span> <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">packet</span> <span class="k">if</span> <span class="n">k</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="s1">&#39;usUnits&#39;</span><span class="p">,</span> <span class="s1">&#39;dateTime&#39;</span><span class="p">)]:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="n">ts</span> <span class="o">=</span> <span class="n">packet</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;dateTime&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">packet</span><span class="p">[</span><span class="s1">&#39;dateTime&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">ts</span><span class="p">)</span> <span class="k">if</span> <span class="n">ts</span> <span class="k">else</span> <span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span> <span class="o">+</span> <span class="mf">0.5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">packet</span>
</span></span></code></pre></div><p>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.</p>
<p>The rest of the changes are small: a <code>units</code> option instead of a hard-coded
unit system, a <code>port</code> option, the configured client id actually being used,
<code>topic</code> accepting a list, and logging through weewx&rsquo;s logger so the messages
show up under <code>journalctl -u weewx</code> with the rest.</p>
<h2 id="the-stanza">The stanza</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[wxMesh]</span>
</span></span><span class="line"><span class="cl">    <span class="na">driver</span> <span class="o">=</span> <span class="s">user.wxMesh
</span></span></span><span class="line"><span class="cl"><span class="s">    host = &lt;broker-address&gt;
</span></span></span><span class="line"><span class="cl"><span class="s">    port = 1883
</span></span></span><span class="line"><span class="cl"><span class="s">    username = &lt;user&gt;
</span></span></span><span class="line"><span class="cl"><span class="s">    password = &lt;password&gt;
</span></span></span><span class="line"><span class="cl"><span class="s">    client = weewx-pi3-1
</span></span></span><span class="line"><span class="cl"><span class="s">    # Quote these. An unquoted # starts a comment in weewx.conf.
</span></span></span><span class="line"><span class="cl"><span class="s">    topic = &#34;rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/#&#34;, &#34;weewx/loop&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">    units = METRIC          # rtl_433 sends degC and hPa
</span></span></span><span class="line"><span class="cl"><span class="s">    poll_interval = 2
</span></span></span><span class="line"><span class="cl"><span class="s">    [[label_map]]
</span></span></span><span class="line"><span class="cl"><span class="s">        TIME = dateTime
</span></span></span><span class="line"><span class="cl"><span class="s">        rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/temperature_C = inTemp
</span></span></span><span class="line"><span class="cl"><span class="s">        rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/humidity = inHumidity
</span></span></span><span class="line"><span class="cl"><span class="s">        rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/pressure_hPa = pressure</span>
</span></span></code></pre></div><p>The <code>weewx/loop</code> topic isn&rsquo;t used by anything yet. It&rsquo;s there so a Home
Assistant automation can publish a <code>TIME:0,extraTemp1:21.5,...</code> payload later
without touching the driver again.</p>
<p><code>pressure</code> is station pressure, which is what the WH25 measures. <code>barometer</code>
still comes from the Vevor. <code>[StdWXCalculate]</code> has both set to
<code>prefer_hardware</code>, so weewx uses the measured value when a packet has one and
computes it otherwise.</p>
<h2 id="the--that-wasnt-a-wildcard">The <code>#</code> that wasn&rsquo;t a wildcard</h2>
<p>The first restart looked fine in the log, until I read the subscription line:</p>
<pre tabindex="0"><code>INFO user.wxMesh: wxMesh: subscribed to rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/
</code></pre><p>The trailing <code>#</code> was gone. <code>weewx.conf</code> is a ConfigObj file, and in ConfigObj
an unquoted <code>#</code> starts an inline comment. The driver had subscribed to a
topic that nothing publishes to. Quoting the values in the <code>topic</code> list fixed
it. It&rsquo;s an easy mistake to make with MQTT, where <code>#</code> shows up in almost every
subscription.</p>
<h2 id="checking">Checking</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">journalctl -u weewx <span class="p">|</span> grep wxMesh
</span></span></code></pre></div><pre tabindex="0"><code>INFO user.wxMesh: wxMesh 0.1-pi3-1: broker &lt;broker-address&gt;:1883, client id weewx-pi3-1, ...
INFO user.wxMesh: wxMesh: connected to &lt;broker-address&gt;:1883 with result code 0
INFO user.wxMesh: wxMesh: subscribed to rtl_433/&lt;host&gt;/devices/Fineoffset-WH25/170/#
INFO user.wxMesh: wxMesh: subscribed to weewx/loop
</code></pre><p>Then the next archive record, from the venv&rsquo;s <code>sqlite3</code> module:</p>
<pre tabindex="0"><code>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
</code></pre><p>24.3 °C in, 75.68 °F out, in the same row as the Vevor&rsquo;s outdoor temperature.</p>
<h2 id="gotchas-collected">Gotchas, collected</h2>
<ul>
<li><strong>Quote topics containing <code>#</code></strong> in <code>weewx.conf</code>, or the wildcard is eaten as
a comment.</li>
<li><strong>MetaDriver never restarts a failed child.</strong> Make sure the secondary driver
can&rsquo;t raise during construction. <code>connect_async()</code> instead of <code>connect()</code>
is the whole fix for MQTT.</li>
<li><strong>A wildcard subscription needs a whitelist.</strong> Map the fields you want and
drop the rest, or <code>battery_ok</code>, <code>mic</code> and friends end up in the packets.</li>
<li><strong>paho-mqtt 2.x needs <code>CallbackAPIVersion</code>.</strong> Any weewx MQTT driver written
before 2024 will hit this.</li>
<li><strong>Restarts log a warning</strong> from MetaDriver: &ldquo;worker thread for driver
&lsquo;Interceptor&rsquo; did not close within 5.0 seconds&rdquo;. Each child&rsquo;s generator
blocks until its next packet, so the stop takes a little longer. The threads
are daemons and the process exits anyway.</li>
<li><strong>Stay with <code>record_generation = software</code>.</strong> It&rsquo;s what lets packets from
two drivers share one archive record.</li>
</ul>
<p>The Pi now records an outdoor station that thinks it&rsquo;s talking to Weather
Underground and an indoor sensor that arrives over MQTT from a different
computer, in one weewx, with one archive.</p>
]]></content:encoded></item><item><title>Tricking a Weather Station Into Uploading to a Raspberry Pi</title><link>https://andylittle.net/blog/2026/weewx-interceptor-isolated-weather-station/</link><pubDate>Thu, 17 Sep 2026 05:00:00 -0700</pubDate><guid>https://andylittle.net/blog/2026/weewx-interceptor-isolated-weather-station/</guid><description>A Vevor weather station that can only upload to Weather Underground, a Raspberry Pi 3 pretending to be wunderground.com on its own isolated wifi network, and weewx-interceptor catching the data. Plus the WPA2 handshake that failed nine times out of ten, and the console clock that was an hour fast.</description><content:encoded><![CDATA[<p>I bought a Vevor weather station. It&rsquo;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.</p>
<p>I wanted the data on my own hardware, recorded by <a href="https://weewx.com/">weewx</a>,
and I didn&rsquo;t want an inexpensive IoT console on my LAN or talking to the
internet at all.</p>
<p>The end state: a Raspberry Pi 3 (<code>pi3-1</code>) runs its own WPA2 access point just
for the station. On that network the Pi&rsquo;s DNS server says it <em>is</em>
<code>wunderground.com</code>. The station uploads to the Pi, thinking it&rsquo;s Weather
Underground, and
<a href="https://github.com/matthewwall/weewx-interceptor">weewx-interceptor</a> parses the
upload and hands it to weewx. The station can&rsquo;t reach the LAN or the internet,
and the weewx reports are served to the LAN by nginx.</p>
<h2 id="how-it-fits-together">How it fits together</h2>
<pre tabindex="0"><code>Vevor console --wifi (WPA2, SSID weewx-ap)--&gt; Pi wlan0 192.168.5.1
  DHCP + DNS: dnsmasq
    wunderground.com -&gt; 192.168.5.1
    every other name -&gt; NXDOMAIN, nothing forwarded upstream
  HTTP to port 80 --nftables redirect--&gt; :8090 weewx-interceptor (wu-client)
    --&gt; weewxd --&gt; SQLite archive
  weewx reports --&gt; /var/www/weewx --&gt; nginx on port 80 (eth0, LAN)
  IP forwarding off + nftables drops all forwarding to/from wlan0
</code></pre><p>The station sends a Weather Underground protocol request, a plain HTTP GET to
<code>/weatherstation/updateweatherstation.php?ID=...&amp;tempf=...&amp;humidity=...</code>, and
the interceptor&rsquo;s <code>wu-client</code> mode already understands that format. Getting
the station to send that request to the Pi only takes a DNS lie and a port
redirect.</p>
<h2 id="hardware-and-host">Hardware and host</h2>
<ul>
<li>Raspberry Pi 3B, Raspberry Pi OS based on Debian 13 (trixie), wired to the
LAN on <code>eth0</code></li>
<li>The Pi 3&rsquo;s built-in BCM43430 wifi (<code>brcmfmac</code> driver, 2.4 GHz only) as the
access point on <code>wlan0</code></li>
<li>Vevor weather station console with a BL602 wifi chip</li>
</ul>
<p>The Pi already runs another service that uses port 8080, which is why the
interceptor listens on 8090 here.</p>
<h2 id="1-base-system">1. Base system</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">apt-get update <span class="o">&amp;&amp;</span> apt-get install -y git hostapd dnsmasq nginx
</span></span><span class="line"><span class="cl">timedatectl set-timezone America/Los_Angeles
</span></span><span class="line"><span class="cl">raspi-config nonint do_wifi_country US
</span></span><span class="line"><span class="cl">rfkill unblock wifi
</span></span></code></pre></div><h2 id="2-weewx-and-the-interceptor">2. weewx and the interceptor</h2>
<p>I installed weewx 5.5 with the git method, in a Python venv. <strong>Do this as your
normal user, not root.</strong> <code>weectl station create</code> writes the current user and
the venv&rsquo;s Python path into the systemd unit it generates.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -m venv ~/weewx-venv
</span></span><span class="line"><span class="cl"><span class="nb">source</span> ~/weewx-venv/bin/activate
</span></span><span class="line"><span class="cl">python3 -m pip install --upgrade pip
</span></span><span class="line"><span class="cl">python3 -m pip install CT3 configobj Pillow ephem
</span></span><span class="line"><span class="cl">git clone https://github.com/weewx/weewx ~/weewx
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">python3 ~/weewx/src/weectl.py station create --no-prompt <span class="se">\
</span></span></span><span class="line"><span class="cl">  --driver<span class="o">=</span>weewx.drivers.simulator --location<span class="o">=</span><span class="s2">&#34;Rocklin, CA&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --latitude<span class="o">=</span>&lt;lat&gt; --longitude<span class="o">=</span>&lt;lon&gt; --altitude<span class="o">=</span>&lt;alt&gt;,foot --units<span class="o">=</span>us
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">python3 ~/weewx/src/weectl.py extension install --yes <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://github.com/matthewwall/weewx-interceptor/archive/master.zip
</span></span></code></pre></div><p>The simulator driver is only there so <code>station create</code> has something to
configure. It gets replaced in the next step. Then, as root, install the
service:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sh ~&lt;user&gt;/weewx-data/scripts/setup-daemon.sh
</span></span></code></pre></div><h2 id="3-weewxconf">3. weewx.conf</h2>
<p>The relevant changes in <code>~/weewx-data/weewx.conf</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Station]</span>
</span></span><span class="line"><span class="cl">    <span class="na">station_type</span> <span class="o">=</span> <span class="s">Interceptor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Interceptor]</span>
</span></span><span class="line"><span class="cl">    <span class="na">driver</span> <span class="o">=</span> <span class="s">user.interceptor
</span></span></span><span class="line"><span class="cl"><span class="s">    device_type = wu-client
</span></span></span><span class="line"><span class="cl"><span class="s">    port = 8090</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[StdReport]</span>
</span></span><span class="line"><span class="cl">    <span class="na">HTML_ROOT</span> <span class="o">=</span> <span class="s">/var/www/weewx</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[StdArchive]</span>
</span></span><span class="line"><span class="cl">    <span class="na">archive_interval</span> <span class="o">=</span> <span class="s">300
</span></span></span><span class="line"><span class="cl"><span class="s">    record_generation = software</span>
</span></span></code></pre></div><p>Two notes:</p>
<ul>
<li><strong><code>record_generation</code> must be <code>software</code>.</strong> The interceptor has no hardware
archive memory to read records from. With <code>hardware</code>, weewx starts up and
logs loop packets, but it never saves an archive record.</li>
<li><code>HTML_ROOT</code> points outside the home directory, at a directory the weewx user
owns. Home directories on this Pi are mode 700, so nginx can&rsquo;t read the
default <code>~/weewx-data/public_html</code>. The smartphone and mobile sub-reports
need their <code>HTML_ROOT</code> moved under <code>/var/www/weewx</code> too.</li>
</ul>
<h2 id="4-patching-the-interceptor">4. Patching the interceptor</h2>
<p>Version 0.60 of the interceptor needed four small fixes for this station, all
in <code>~/weewx-data/bin/user/interceptor.py</code>. Keep a copy of the original, because
reinstalling the extension overwrites your patches.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gu">@@ do_POST
</span></span></span><span class="line"><span class="cl"><span class="gd">-            data = str(self.rfile.read(length))
</span></span></span><span class="line"><span class="cl"><span class="gi">+            data = _bytes_to_str(self.rfile.read(length))
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">@@ WUClient LABEL_MAP
</span></span></span><span class="line"><span class="cl">             &#39;solarradiation&#39;: &#39;solar_radiation&#39;,
</span></span><span class="line"><span class="cl"><span class="gi">+            &#39;solarRadiation&#39;: &#39;solar_radiation&#39;,
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">@@ WUClient IGNORED_LABELS
</span></span></span><span class="line"><span class="cl"><span class="gi">+            &#39;rainin&#39;,
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">@@ WUClient.Parser.parse
</span></span></span><span class="line"><span class="cl"><span class="gd">-                pkt[&#39;dateTime&#39;] = self.decode_datetime(
</span></span></span><span class="line"><span class="cl"><span class="gd">-                    data.pop(&#39;dateutc&#39;, int(time.time() + 0.5)))
</span></span></span><span class="line"><span class="cl"><span class="gi">+                # Console clock is 1 h fast (no DST) and has no NTP: use the Pi clock
</span></span></span><span class="line"><span class="cl"><span class="gi">+                data.pop(&#39;dateutc&#39;, None)
</span></span></span><span class="line"><span class="cl"><span class="gi">+                pkt[&#39;dateTime&#39;] = int(time.time() + 0.5)
</span></span></span></code></pre></div><p>What each one fixes:</p>
<ol>
<li><strong>Python 3 bytes bug.</strong> <code>str()</code> on a <code>bytes</code> object gives you the string
<code>&quot;b'...'&quot;</code>, not the decoded body. The parsed values then fail with
<code>could not convert string to float</code>. The module already has a
<code>_bytes_to_str</code> helper. It just wasn&rsquo;t used here.</li>
<li><strong>camelCase label.</strong> The Vevor sends <code>solarRadiation</code> where the interceptor
expects <code>solarradiation</code>. Without the extra mapping, the log fills with
&ldquo;unrecognized parameter&rdquo; warnings and solar radiation is never recorded.</li>
<li><strong>Rain.</strong> The station sends both <code>rainin</code> (rain in the last hour) and
<code>dailyrainin</code>. weewx works out rain per interval from the running daily
total, so <code>rainin</code> is ignored to avoid counting the same rain twice.</li>
<li><strong>Timestamps.</strong> The console&rsquo;s clock was an hour ahead. It doesn&rsquo;t handle
daylight saving time, and on an isolated network it can&rsquo;t reach an NTP
server to correct itself. Records were being stamped an hour in the future.
The Pi&rsquo;s clock is correct, so the patch ignores <code>dateutc</code> and stamps each
packet when it arrives.</li>
</ol>
<h2 id="5-the-access-point-hostapd-not-networkmanager">5. The access point: hostapd, not NetworkManager</h2>
<p>This part took the most time.</p>
<p>Raspberry Pi OS uses NetworkManager, and NetworkManager can run a wifi hotspot.
An <em>open</em> hotspot worked fine. A WPA2 hotspot never completed a handshake with
any client, not just the weather station. A kernel and firmware upgrade didn&rsquo;t
change that. Switching to plain <code>hostapd</code> got WPA2 working.</p>
<p>First, tell NetworkManager to leave <code>wlan0</code> alone
(<code>/etc/NetworkManager/conf.d/99-weewx-ap.conf</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[keyfile]</span>
</span></span><span class="line"><span class="cl"><span class="na">unmanaged-devices</span><span class="o">=</span><span class="s">interface-name:wlan0</span>
</span></span></code></pre></div><p>Then <code>/etc/hostapd/hostapd.conf</code>:</p>
<pre tabindex="0"><code>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=&lt;your passphrase&gt;
eapol_version=1
wpa_pairwise_update_count=10
</code></pre><h3 id="the-slow-handshake">The slow handshake</h3>
<p>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:</p>
<pre tabindex="0"><code>WPA: PTKSTART: Retry limit 4 reached
</code></pre><p>The station&rsquo;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:</p>
<ul>
<li><code>wpa_pairwise_update_count=10</code> lets hostapd resend handshake messages up to
10 times instead of 4, so a slow client has time to answer.</li>
<li><code>eapol_version=1</code> makes hostapd use the older EAPOL version, which some
embedded wifi stacks handle better.</li>
</ul>
<p>After that change, the station joined on the first try every time. If you
turned up hostapd&rsquo;s logging to debug this (<code>logger_syslog_level=0</code>), turn it
back down afterwards.</p>
<h2 id="6-dhcp-and-the-dns-lie">6. DHCP and the DNS lie</h2>
<p><code>/etc/dnsmasq.d/weewx-ap.conf</code>:</p>
<pre tabindex="0"><code>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=/#/
</code></pre><p><code>address=/wunderground.com/...</code> covers the domain and all its subdomains, so
<code>rtupdate.wunderground.com</code> and <code>weatherstation.wunderground.com</code> both resolve
to the Pi. <code>address=/#/</code> with no address answers every other name with
NXDOMAIN. <code>no-resolv</code> means dnsmasq never asks an upstream server, so the
station can&rsquo;t learn a real address for anything.</p>
<p>I used 192.168.5.0/24 for the AP subnet because the console&rsquo;s own setup hotspot
uses 192.168.4.1, and I didn&rsquo;t want the two to overlap.</p>
<h2 id="7-redirect-and-isolation-firewall">7. Redirect and isolation firewall</h2>
<p>A small script sets the address on <code>wlan0</code> and loads an nftables table, at
<code>/usr/local/sbin/weewx-ap-net</code> (mode 755):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -e
</span></span><span class="line"><span class="cl">ip link <span class="nb">set</span> wlan0 up
</span></span><span class="line"><span class="cl">ip addr replace 192.168.5.1/24 dev wlan0
</span></span><span class="line"><span class="cl">nft delete table inet weewx 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl">nft -f - <span class="s">&lt;&lt;&#39;NFT&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">table inet weewx {
</span></span></span><span class="line"><span class="cl"><span class="s">	chain prerouting {
</span></span></span><span class="line"><span class="cl"><span class="s">		type nat hook prerouting priority dstnat; policy accept;
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname &#34;wlan0&#34; tcp dport 80 counter redirect to :8090
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname &#34;wlan0&#34; tcp dport 443 counter
</span></span></span><span class="line"><span class="cl"><span class="s">	}
</span></span></span><span class="line"><span class="cl"><span class="s">	chain input {
</span></span></span><span class="line"><span class="cl"><span class="s">		type filter hook input priority filter; policy accept;
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname != &#34;wlan0&#34; accept
</span></span></span><span class="line"><span class="cl"><span class="s">		ct state established,related accept
</span></span></span><span class="line"><span class="cl"><span class="s">		udp dport 67 accept
</span></span></span><span class="line"><span class="cl"><span class="s">		meta l4proto { tcp, udp } th dport 53 accept
</span></span></span><span class="line"><span class="cl"><span class="s">		tcp dport 8090 accept
</span></span></span><span class="line"><span class="cl"><span class="s">		icmp type echo-request accept
</span></span></span><span class="line"><span class="cl"><span class="s">		counter drop
</span></span></span><span class="line"><span class="cl"><span class="s">	}
</span></span></span><span class="line"><span class="cl"><span class="s">	chain forward {
</span></span></span><span class="line"><span class="cl"><span class="s">		type filter hook forward priority filter; policy accept;
</span></span></span><span class="line"><span class="cl"><span class="s">		iifname &#34;wlan0&#34; counter drop
</span></span></span><span class="line"><span class="cl"><span class="s">		oifname &#34;wlan0&#34; counter drop
</span></span></span><span class="line"><span class="cl"><span class="s">	}
</span></span></span><span class="line"><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="cl"><span class="s">NFT</span>
</span></span></code></pre></div><ul>
<li><strong>prerouting</strong> sends the station&rsquo;s HTTP requests to the interceptor. The
port 443 rule only counts packets, so you can see whether the station ever
tries HTTPS.</li>
<li><strong>input</strong> lets wifi clients reach DHCP, DNS, the interceptor and ping, and
nothing else on the Pi. SSH and nginx can&rsquo;t be reached from <code>wlan0</code>.</li>
<li><strong>forward</strong> drops all traffic to or from <code>wlan0</code>. IP forwarding is also
left <strong>off</strong> (no <code>net.ipv4.ip_forward=1</code>, no masquerade), so the
isolation doesn&rsquo;t depend on a single setting.</li>
</ul>
<p>A oneshot unit runs the script at boot
(<code>/etc/systemd/system/weewx-ap-net.service</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">weewx AP: wlan0 address, port redirect and isolation firewall</span>
</span></span><span class="line"><span class="cl"><span class="na">Before</span><span class="o">=</span><span class="s">hostapd.service dnsmasq.service</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">NetworkManager.service</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">RemainAfterExit</span><span class="o">=</span><span class="s">yes</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/sbin/weewx-ap-net</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span>
</span></span></code></pre></div><p>hostapd and dnsmasq each get a drop-in
(<code>/etc/systemd/system/hostapd.service.d/weewx-ap.conf</code> and the same path for
<code>dnsmasq.service.d</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">weewx-ap-net.service</span>
</span></span><span class="line"><span class="cl"><span class="na">Wants</span><span class="o">=</span><span class="s">weewx-ap-net.service</span>
</span></span></code></pre></div><p>Use <code>Wants=</code>, not <code>Requires=</code>. With <code>Requires=</code>, restarting <code>weewx-ap-net</code> to
reload the firewall also restarts hostapd, and the station is kicked off the
network.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">systemctl daemon-reload
</span></span><span class="line"><span class="cl">systemctl unmask hostapd
</span></span><span class="line"><span class="cl">systemctl <span class="nb">enable</span> --now weewx-ap-net hostapd dnsmasq
</span></span></code></pre></div><h2 id="8-serving-the-reports">8. Serving the reports</h2>
<p><code>/etc/nginx/sites-available/weewx</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span> <span class="s">default_server</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">listen</span> <span class="s">[::]:80</span> <span class="s">default_server</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">server_name</span> <span class="s">_</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">root</span> <span class="s">/var/www/weewx</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">index</span> <span class="s">index.html</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span> <span class="kn">try_files</span> <span class="nv">$uri</span> <span class="nv">$uri/</span> <span class="p">=</span><span class="mi">404</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rm /etc/nginx/sites-enabled/default
</span></span><span class="line"><span class="cl">ln -s /etc/nginx/sites-available/weewx /etc/nginx/sites-enabled/weewx
</span></span><span class="line"><span class="cl">systemctl reload nginx
</span></span></code></pre></div><p>LAN clients get the weewx Seasons report at <code>http://&lt;pi-address&gt;/</code>. Devices on
the station&rsquo;s wifi can&rsquo;t load it, because port 80 on <code>wlan0</code> goes to the
interceptor. That&rsquo;s fine, since the station is the only thing on that network.</p>
<h2 id="9-pointing-the-console-at-it">9. Pointing the console at it</h2>
<p>On the Vevor console&rsquo;s setup page, join the <code>weewx-ap</code> network and enable
Weather Underground with any station ID and key (<code>1234</code> works, since the
interceptor doesn&rsquo;t check them). WeatherCloud can stay enabled. Its DNS
lookups get NXDOMAIN and it quietly fails.</p>
<h2 id="checking-that-it-works">Checking that it works</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">iw dev wlan0 station dump                 <span class="c1"># station is associated</span>
</span></span><span class="line"><span class="cl">cat /var/lib/misc/dnsmasq.leases          <span class="c1"># and got a 192.168.5.x lease</span>
</span></span><span class="line"><span class="cl">journalctl -u hostapd <span class="p">|</span> grep <span class="s2">&#34;handshake completed&#34;</span>
</span></span><span class="line"><span class="cl">nft list table inet weewx                 <span class="c1"># port-80 counter rising, drop counters at 0</span>
</span></span><span class="line"><span class="cl">journalctl -u weewx -f                    <span class="c1"># &#34;Added record ...&#34; every 5 minutes</span>
</span></span><span class="line"><span class="cl">curl http://&lt;pi-address&gt;/                 <span class="c1"># weewx Seasons page</span>
</span></span></code></pre></div><p>In <code>nft list table inet weewx</code>, 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 &ldquo;Weather
Underground&rdquo;.</p>
<h2 id="gotchas-collected">Gotchas, collected</h2>
<ul>
<li><strong>NetworkManager&rsquo;s WPA2 hotspot on the Pi 3 (brcmfmac) never completed a
handshake.</strong> An open hotspot worked. hostapd fixed it.</li>
<li><strong>The BL602 chip answers the handshake slowly.</strong> Set
<code>wpa_pairwise_update_count=10</code> and <code>eapol_version=1</code>, or expect &ldquo;Retry limit
4 reached&rdquo;.</li>
<li><strong>The interceptor&rsquo;s POST handling has a Python 3 bug</strong>, which shows up as
<code>could not convert string to float</code>.</li>
<li><strong><code>record_generation = hardware</code> saves no records</strong> with the interceptor.
Use <code>software</code>.</li>
<li><strong>The console clock is an hour fast</strong> and can&rsquo;t sync on an isolated
network, so use the Pi&rsquo;s clock for timestamps.</li>
<li><strong>Don&rsquo;t overlap the console&rsquo;s setup subnet</strong> (192.168.4.x).</li>
<li><strong><code>sqlite3</code> isn&rsquo;t installed by default.</strong> Query the archive with the venv&rsquo;s
Python <code>sqlite3</code> module instead of installing the CLI.</li>
<li><strong>Run git in <code>~/weewx</code> as the weewx user.</strong> As root, git refuses with a
&ldquo;dubious ownership&rdquo; error.</li>
</ul>
<p>The station now posts its &ldquo;Weather Underground&rdquo; updates to a Pi in the same
house. The station has no idea, and Weather Underground never hears from it.</p>
]]></content:encoded></item></channel></rss>