Hacking the FT-0310 Weather Station

So the title may be a bit misleading, but hell, it might help you find what you’re looking for.

I recently replaced my 10-year old wired Arduino-based weather station (DHT22 + BMP085 + background radiation via SMB-20 geiger tube mounted in a wooden screen in the garden) with a spanking new NicetyMeter 0310 Professional WiFi Weather Station which, in spite of its daft brand name, turned out to be a fine purchase.

The Hardware

The weather station consists of a Wifi-enabled display console (3xAAA powered, with an optional power adapter which turns out not to be at all optional if you want to use WIFI logging) and an integrated outdoor sensor which looks suspiciously like a Davis Vantage Vue (photos below taken from the seller’s website) with an UV and light sensor. It comes with a full mounting kit and the main difference between it and its ultra-expensive ‘inspiration’ is the fact that this sensor doesn’t charge the backup batteries (i.e. the solar panel is only used to power the transmitter during the daytime, 3xAA batteries power it during the night) so batteries need to be replaced occasionally, the manufacturer claims they should last more than a year. Update: The low battery level warning indicator went off after 19 months in operation.

A casual google search shows that this particular combination of display console and sensor is sold under several brands (including Sainlogic WS 0310 and Ragova) while the outdoor sensor unit is used by even more brands (more on that later). The manufacturer seems to be StarMeter Instruments.

For what it’s worth, it’s a good value for money. The housing seems to made from solid ABS and doesn’t feel flimsy or cheap.  One issue I have with the external console is the plastic mounting pole that tends to wobble slightly, activating the rain sensor tipping cups during heavy wind gusts, but I assume this is partially caused by my choice of mounting location, which is on a 1 metre high antenna pole atop a ladder mounted on the side of my house.

Getting its data

In addition to displaying the data, the display console can upload it to Weather Underground and Weathercloud. The user manual is written well (it’s not Chinglish), so it’s easy to set up logging and for most most people this would be enough, but I wouldn’t be me if I just kept it at that. Both services have their limitations – Weathercloud only allows 10-minute updates and access to only a year’s worth of data without a subscription, while Wunderground accepts 1-minute updates (the station console doesn’t support rapidfire updates) but the free API key you get if you register a weather station is limited to 1500 queries PER DAY, which means you’re only allowed to query their servers once a minute.

My priority was to back up my station’s observations (and post them to other weather observation websites) as best as I could until I find a different solution, so I’ve set up leoherzog‘s brilliantly simple WundergroundStationForwarder Google script to work with my station’s data and publish it to some additional backends. It’s a hacky solution, but seems to work pretty reliably. Once I got that sorted, it was time to look under the hood. After all, the sensor transmits every 16 seconds, and yet I get online data every 60 seconds. I won’t have any of that nonsense!

The Teardown

Since the probability that the display console had an ESP8266 or something similar inside was reasonably high (the Soft AP configuration and the WeatherHome-CFxxxxx were dead giveaways), I hoped I would be able to find a debug port and use that to gain access to data.

It turned out that I was right-ish. The console’s WiFi connectivity is powered by an ESP-WROOM-02D-V1.4, but its sole purposes seem to be web logging and NTP time sync. The four pin holes (P5 header) next to it are, top to bottom, VCC, GND, TXD, RXD.

The UART0 port has no useful debugging information apart from the typical boot debug, so I assume it’s intended for flashing. A boot log looks like this:

ets Jan  8 2013,rst cause:1, boot mode:(7,7)

waiting for host
ets Jan  8 2013,rst cause:1, boot mode:(7,7)

waiting for host

ets Jan  8 2013,rst cause:2, boot mode:(3,7)

load 0x40100000, len 2592, room 16 
tail 0
chksum 0xf3
load 0x3ffe8000, len 764, room 8 
tail 4
chksum 0x92
load 0x3ffe82fc, len 676, room 4 
tail 0
chksum 0x22
csum 0x22

2nd boot version : 1.7(5d6f877)
SPI Speed : 40MHz
SPI Mode : QIO
SPI Flash Size & Map: 16Mbit(1024KB+1024KB)
jump to run user1 @ 1000

OS SDK ver: 2.0.0(e271380) compiled @ Mar 30 2018 18:54:06
phy ver: 1055_1, pp ver: 10.7

rf cal sector: 507
tcpip_task_hdl : 40107b28, prio:10,stack:512
idle_task_hdl : 40107bd8,prio:0, stack:384
tim_task_hdl : 40107d20, prio:2,stack:512
Load param success
Timer Server: time.windows.com
20000128128128000001281280
 ets Jan  8 2013,rst cause:2, boot mode:(3,6)

load 0x40100000, len 2592, room 16 
tail 0
chksum 0xf3
load 0x3ffe8000, len 764, room 8 
tail 4
chksum 0x92
load 0x3ffe82fc, len 676, room 4 
tail 0
chksum 0x22
csum 0x22

2nd boot version : 1.7(5d6f877)
SPI Speed : 40MHz
SPI Mode : QIO
SPI Flash Size & Map: 16Mbit(1024KB+1024KB)
jump to run user1 @ 1000

OS SDK ver: 2.0.0(e271380) compiled @ Mar 30 2018 18:54:06
phy ver: 1055_1, pp ver: 10.7

rf cal sector: 507
tcpip_task_hdl : 40107b28, prio:10,stack:512
idle_task_hdl : 40107bd8,prio:0, stack:384
tim_task_hdl : 40107d20, prio:2,stack:512
Load param success
Timer Server: time.windows.com
20000128128128000001281280
Mainboard, bottom view

The board also has two microprocessors (custom chip-on-board, covered in epoxy resin) which run the LCD display and everything else.

“Everything else” includes:

  • A two-wire I2C EEPROM and a barometric pressure sensor. There’s a pin breakout for the EEPROM.
  • A 433 MHz receiver with its own pin breakout
  • A humidity sensor

Since the ESP is connected to the main processor(s) via IO 12, IO 13, IO 14 and IO 15, it is evident that it’s using the SPI protocol, but I figured it would be too time-consuming to reverse-engineer the communication and since the processor only talks to the ESP module when a web update is required (i.e. once a minute), I realised that I will just have to get the data directly from the outdoor transmitter.

Sniffing data from the outdoor sensor

The next step was to break out my cheapo RTL2832U-based SDR receiver (25 MHz to 1760 MHz version, yes, the blue 10$ one) and see what it can find with the aid of RTL_433 decoder. It did not disappoint. The latest version of RTL_433 identified it as Cotech-367959 (or other stations that use the same sensor, SwitchDoc Labs Weather FT020T,  Sainlogic Weather Station WS019T and probably many more).

RTL_433 identifies the station as Cotech-36759

Please note that at the time of writing only the latest version supports it fully and properly decodes UV index and light intensity, the Debian package (installed via apt install rtl-433) did not have UV index and Light Intensity, so I had to build it for my Orange Pi Zero running Armbian.

Using the data

This meant I could easily get the weather info and use it in WeeWX via the weewx-sdr driver. However, the driver doesn’t currently include support for Cotech-367959, so you need to add this class into the driver’s sdr.py. It’s usually located under weewx/user/sdr.py after installation.

class Cotech367959Packet(Packet):
    # ['{"time" : "2022-04-28 14:25:59", "model" : "Cotech-367959", "id" : 109, "battery_ok" : 1, "temperature_F" : 63.000, "humidity" : 55, "rain_mm" : 16.200, "wind_dir_deg" : 296, "wind_avg_m_s" : 0.900, "wind_max_m_s" : 1.300, "light_lux" : 23093, "uv" : 14, "mic" : "CRC"}']
    IDENTIFIER = "Cotech-367959"

    @staticmethod
    def parse_json(obj):
        pkt = dict()
        pkt['dateTime'] = Packet.parse_time(obj.get('time'))
        pkt['usUnits'] = weewx.METRIC
        sensor_id = obj.get('id') # changes every time you reset the outdoor sensor
        pkt['battery'] = 0 if obj.get('battery') == 'OK' else 1
        pkt['temperature'] = to_C(Packet.get_float(obj, 'temperature_F'))
        pkt['humidity'] = Packet.get_float(obj, 'humidity')
        pkt['wind_gust'] = Packet.get_float(obj, 'wind_max_m_s')
        pkt['wind_speed'] = Packet.get_float(obj, 'wind_avg_m_s')
        pkt['wind_dir'] = Packet.get_float(obj, 'wind_dir_deg')
        pkt['total_rain'] = Packet.get_float(obj, 'rain_mm')
        pkt['light_lux'] = Packet.get_int(obj, 'light_lux')
        pkt['uv'] = Packet.get_int(obj, 'uv') / 10
        pkt = Packet.add_identifiers(pkt, sensor_id, Cotech367959Packet.__name__)
        return pkt

Also bear in mind that the id value changes whenever you reset the outdoor sensor, so if you’ve correctly identified your station and plan on using it continuously, you might want to set a fixed value there.

As noted in the comments below, the original sdr.py doesn’t provide a function that converts degrees Fahrenheit to Celsius, so you should also add one – just insert the following (utility functions start from line 169).

def to_C(v):
    if v is not None:
        v  = 5 / 9 * (v - 32)
    return v

To use this data in WeeWX, you also need to map the values in /etc/weewx/weewx.conf:

[SDR]
    # This section is for the software-defined radio driver.
    # The driver to use
    driver = user.sdr

    [[sensor_map]]
        windDir = wind_dir.109.Cotech367959Packet
        windSpeed = wind_speed.109.Cotech367959Packet
        windGust = wind_gust.109.Cotech367959Packet
        outTemp = temperature.109.Cotech367959Packet
        outHumidity = humidity.109.Cotech367959Packet
        rain_total = total_rain.109.Cotech367959Packet
        UV = uv.109.Cotech367959Packet
        luminosity = light_lux.109.Cotech367959Packet

Note that the 109 is my station ID, yours will be different (unless you change it or set a fixed value, which I recommend, see above).

So now I had almost all the data I needed, apart from barometric pressure. For this purpose, I connected a BME180 breakout board to my OrangePi/Raspberry Pi using the I2C port. To get the data into WeeWX, I used this Bme280wx driver (not a typo, BME180/280 are interchangeble).

I had to combine the data – mapping the BME180 data to inHumidity, inTemp and pressure values in /etc/weewx/weewx.conf as shown below.

[Bme280wx]
i2c_port = 0 # OrangePi and older Raspberry Pi's use 0, usually it's 1
i2c_address = 0x76
usUnits = US
temperatureKeys = inTemp
temperature_must_have = ""
pressureKeys = pressure
pressure_must_have = outTemp
humidityKeys = inHumidity
humidity_must_have = ""

Additionally, you need to tell WeeWX to load this additional driver in the Services section of weewx.conf.

[Engine]

    # The following section specifies which services should be run and in what order.
    [[Services]]
        data_services = "", user.bme280wx.Bme280wx

station_type value in the [Station] section should be defined as SDR since RTL-SDR is your main driver and BME280wx your additional driver.

[Station]
    [...]

    # Set to type of station hardware. There must be a corresponding stanza
    # in this file, which includes a value for the 'driver' option.
    station_type = SDR

My Weewx with this data is accessible at https://meteo-valpovo.sh.com.hr/.

The FUTURE

Since I’m not too confident about my cheapo SDR receiver, my plan is to set up a dedicated ESP32 or ESP8266-based 433 MHz receiver and forwarder if I manage to implement it (or if someone smarter ports the RTL-433 project to a suitable library).

Having a local backup of my weather data and an alternative method of posting it to online services which doesn’t rely on my closed-source display console is enough for now, since I doubt I’d get a firmware upgrade if Weather Underground or WeatherCloud suddenly decided to change their API’s.

 

Digital signage solution for a school

This time around, I’ll be documenting a simple and cheap project I did for my workplace – a digital signage solution for the school I work at. Our plan was to get a larger LED TV, mount it in the main hall and use it to display information and reminders about upcoming events, activities and so on. Parents often have to wait for their children in this same hall, so they would also have something to check out as they wait. 

Trying to find the best way to do this, I’ve immediately discounted all offline solutions which would involve running photos or video off a USB stick on that TV because I needed a way to allow a limited staff to edit and update the information. Since the TV at my disposal had an HDMI port, a Raspberry Pi seemed like the perfect choice. However, after reviewing a large number of commercial, freemium and free digital signage solutions, none of them turned out to be a clear winner – they were either too complicated to edit or focused on video, a complete overkill for displaying a few lines of text or a couple of photos.

I figured the best way would be to present a fullscreen website in a browser. Again, I tried out a few different ideas, including a digital signage WordPress theme, but it lacked some editing options. Instead, I decided to show an HTML version of an online presentation tool like Microsoft’s PowerPoint online or Google Docs Slides. Office Online gave me loads of trouble providing public access to the plain HTML version, so Google Docs Slides was the only choice left.

After creating a presentation and using the Publish to the Web option, I received an embed code to display fullscreen using the Chromium browser on the Raspberry Pi. However, the actual fullscreen view looked like this:

As you can see, the display is not actually fullscreen because Google adds a toolbar at the bottom and there is no publishing option that would allow you to remove it. However, there is an undocumented (or semi-documented) flag, “rm=minimal” which removes the toolbar, so my final embed URL looked like this:

https://docs.google.com/presentation/d/[PRESENTATION ID]/embed?start=true&loop=true&delayms=15000&rm=minimal

Since it was clear I would use HTML/CSS to display an iFrame with the presentation, I decided to overlay a clock and some minimal weather info (current temperature) using JavaScript.

HTML/JavaScript is freely accessible on the production site I load on the RPi, available at https://kiosk.oskatancic.hr/, but just to be on the safe side, the source is included in this post. (Sorry, inline comments are partly in Croatian).

<!DOCTYPE html>
<html>
  <head>
    <link href="https://fonts.googleapis.com/css?family=Droid+Sans+Mono|Days+One|Source+Sans+Pro" rel="stylesheet" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <meta charset="utf-8" />
    <title>Kiosk</title>
    <style>
html, body {
	background: white;
	padding: 0;
	margin: 0;
	width: 100%;
	height: 100%;
}

.container {
	width: 100%;
	height: 100%;
	overflow: hidden;
    z-index: 1;
}
.container iframe {
	height: 100%;
	width: 100%;
}

.boks {
    font-family: 'Droid Sans Mono', monospace;
	color: red;
	position: absolute;
	right: 2%;
	top: 5%;
    z-index: 2;
	width: 24%;
	height: 9%;
    font-weight: bold;
	display: flex;
    align-items: center;
    justify-content: center;
    font-size: 5vw;
	background: linear-gradient(#a6e3f8fa, #b9e9fafa);  
}

.kalendar {
    font-family: 'Helvetica', sans-serif;
    font-weight: bold;
	color: #1155cc;
	position: absolute;
	bottom: 1%;
	left: 6%;
	z-index: 2;
	width: 90%;
	height: 3.4vw;
	font-size: 2.9vw;
	overflow: hidden;
	background: #e5e5e5;
	overflow: hidden;
    margin: 0;
    padding: 0;
    list-style: none;
}
.kalendar li {
	height: 3.4vw;
	padding: 0px;
	margin: 0px 5px;
}

#boks2 {
	top: calc(5% + 9%);
	background: linear-gradient(#b9e9fa90, #c7edfa90);  
    font-family: 'Days One', sans-serif;
    font-weight: normal;
}

.clock {
}
ul {
	list-style: none;
	padding-left: 0px;
}

ul li {
	display: inline;
}

#point {
	position: relative;
    padding-left: 0px;
    padding-right: 0px;
}
#weather-temperature {
	z-index: 2;
}

/* Animations */
@-webkit-keyframes blink {
	0% { opacity: 1.0;}
	50% { opacity: 0;}
	100% { opacity: 1.0;};      
}

@-moz-keyframes blink {
	0% {opacity: 1.0;}        
	50% {opacity: 0;}
	100% {opacity: 1.0;};
}
</style>
  </head>
  <body>
  <div class="container">
    <iframe id="presentFrame"
    src="https://docs.google.com/presentation/d/[ID_PREZENTACIJE]/embed?start=true&amp;loop=true&amp;delayms=15000&amp;rm=minimal"
    frameborder="0" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>
  </div>
  <div class="boks">
    <div class="clock">
      <ul>
        <li id="hours"></li>
        <li id="point">:</li>
        <li id="min"></li>
        <li id="point">:</li>
        <li id="sec"></li>
      </ul>
    </div>
    <br />
  </div>
  <div class="boks" id="boks2">
    <div id="weather-temperature"></div>
  </div>
  <div id="kalendar-data" class="kalendar-boks"></div>
  <script src="js/jquery-1.12.4.min.js"></script> 
  <script type="text/javascript">
// Javascript za iscrtavanje sata
$(document).ready(function() {
        setInterval( function() {
                $(".clock").css("visibility", "visible");
                // Create a new Date() object and extract the seconds, minutes and hours...
                var time = new Date();
                var seconds = time.getSeconds();
                var minutes = time.getMinutes();
                var hours = time.getHours();
                $("#sec").html(( seconds < 10 ? "0" : "" ) + seconds);
                $("#min").html(( minutes < 10 ? "0" : "" ) + minutes);
                $("#hours").html(( hours < 10 ? "0" : "" ) + hours);
        },1000);
        
});
</script> 
  <script type="text/javascript">
// Osvježavanje temperature
$(function worker(){
        $.ajaxSetup ({
cache: false,
complete: function() {
	setTimeout(worker, 10*60*1000);
                }
        });
        // load() functions
        var loadUrl = "temp";
        $("#weather-temperature").html("...");
        $("#weather-temperature").load(loadUrl);
        // end  
});
</script> 
  <script type="text/javascript">
// osvježavanje prezentacije
var refreshMinutes = 30;
var timer=setInterval(function(){refreshFrame()}, refreshMinutes*60*1000);
function refreshFrame()
{
        // var iframe = document.getElementById('presentFrame');
        // var iframeURL = iframe.src;
        // iframe.src = iframeURL;
        window.location.reload(true);
}
</script> 
  <script type="text/javascript">
// Ispis kalendara
$(function worker(){
        $.ajaxSetup ({
cache: false,
complete: function() {
	setTimeout(worker, 10*60*1000);
	}
	});
	// load() functions
	var loadUrl = "kalendar.cache";
	$("#kalendar-data").html("...");
	$("#kalendar-data").load(loadUrl);
	// end  
});
</script> 
<script type="text/javascript">
  // ticker kalendara
function tick(){
    $('#kalendar li:first').fadeToggle( function () { $(this).appendTo($('#kalendar')).fadeToggle(); });
}
setInterval(function(){ tick () }, 6000);
</script></body>
</html>

Temperature data is polled from a PHP script which gets it from a local database; if you don’t have one, you can use OpenWeatherMap.

Google Calendar events are polled from a different PHP script which uses the Google Docs API to import events and formats it as a unnumbered list.

<?php
setlocale(LC_ALL, 'hr_HR.UTF-8');
$apikey = 'API_KEY'; // YOUR API key here!
$sada = date(DATE_ATOM);
$limit = new DateTime();
$limit = $limit->add(new DateInterval("P1M"))->format(DATE_ATOM);
$sada = urlencode($sada);
$limit = urlencode($limit);
$brojrezultata = 100;
$adresa = "https://www.googleapis.com/calendar/v3/calendars/oskatancic.hr_[ID_KALENDARA]%40group.calendar.google.com/events?maxResults=$brojrezultata&orderBy=startTime&singleEvents=true&timeMin=$sada&timeMax=$limit&key=$apikey";
$response = file_get_contents($adresa);
$eventi = json_decode($response, true)['items'];
	
echo "<ul id=\"kalendar\" class=\"kalendar\">";
if (!empty($eventi))
{
foreach ($eventi as $dogadjaj)
{
	if (array_key_exists('dateTime', $dogadjaj['start']))
	{
	$dan =  strftime("%A, %e.%-m", strtotime($dogadjaj['start']['dateTime']));
	$pocetak =  date("G:i", strtotime($dogadjaj['start']['dateTime']));
	$recenica_vrijeme = "$dan u $pocetak sati"; 
	}
	else if (array_key_exists('date', $dogadjaj['start']))
	{
	$dan =  strftime("%A, %e.%-m", strtotime($dogadjaj['start']['date']));
	$recenica_vrijeme = "$dan"; 
	}
	$opis = $dogadjaj['summary'];
	echo "<li style=\"display: list-item\">$recenica_vrijeme: $opis</li>";
}
}
else
{
	echo "Nema najavljenih događaja";
}
echo "</ul>"; 
?>

As for the Raspberry Pi itself, I used the latest version of Raspbian (desktop) and made a few changes.

  1. Using raspi-conf  I enabled autologin for “pi” user
  2. Configured the wireless network via Desktop
  3. Installed unclutter to hide the mouse 
    sudo apt install unclutter 
  4. Edited the autostart file for the “pi” user, located at  ~/.config/lxsession/LXDE-pi/autostart
    @lxpanel --profile LXDE-pi
    @pcmanfm --desktop --profile LXDE-pi
    @xscreensaver -no-splash
    @point-rpi
    @unclutter -idle 0.1
    @xset s off
    @xset -dpms
    @xset s noblank
    @chromium-browser --app=https://kiosk.oskatancic.hr/ --temp-profile --no-touch-pinch --kiosk
    

Another good idea is to enable a Chromium flag which would automatically reload the page once the browser goes online (in case of network connectivity losses). This can be done through chromium://flags.

Of course, this whole project is in a perpetual work-in-progress state, so don’t expect all this to work for you out of the box.