Introduction
Late last year, Shelly ran a special on their version of the Ecowitt WS90 (specs here), and I bought one, mostly because I have always wanted a weather station and this one has no moving parts: No wind cups or rain tipping bucket to lead to maintenance issues, but also, because I thought the alternative design was interesting from an electronics point of view.
It sat in its box until July, because I could not work out how to mount it without too much effort…
The raw readings, which arrive over Bluetooth via BTHome, are mostly not what you’d put in front of a person. Most of the work in this project was turning good measurements into useful ones.
I tried to use standard meteorological definitions where they exist, for accuracy, but also for the sake of learning: A sustained wind has a defined averaging period, pressure tendency has a defined window and rain intensity have agreed bands. Inventing my own would have meant nothing I measured could be compared with anything else.
Mounting it
This part spanned months, because we have a toddler at home and finding a contiguous couple of hours over a weekend to do some ad hoc shopping and trying ideas can be challenging.
Our house has a 25° tile roof with no directly exposed vertical face anywhere useful (soffits all around), and the garden is full of large trees. While an L-bracket could be mounted, it would’ve had to be several meters tall to portrude above the roof’s ridge for proper clearance.
I searched for ridge mounts and found nothing locally. I looked at antenna mounts and dish brackets. I looked at Starlink roof mounts. I contacted local antenna installers, and nobody had a solid idea.
My first attempt was two solar mounts with a solar rail and an adjustable antenna mount with U-bolts.
Installing it meant moving roof tiles around like a sliding puzzle (some are fixed with nails, and you don’t know which, plus you need to find ones that will expose the rafters), grinding a couple for clearance, and losing most of the skin on my hands until I bought some gloves. At that point I felt qualified to install my own solar panels.
Unfortunately, this turned out to be an excellent pendulum.
I tried to move the vertical mount to the sides (closer to the solar mounts). I tried rigid members to form triangles, but that only removed one axis/degree of freedom. I felt a bit embarrassed, but first-year engineering statics is more than 25 years ago now, and I’m an electronic/electrical engineer anyway (by qualification, at least).
I also didn’t want to end up with something unsightly. I knew my only real option was to install more solar mounts to anchor some guy wires. It turned out solid and has withstood a couple of windstorms by now.
Final BOM:
- 4 solar clamps, and a rig bracket fixed to the ridge
- 1 piece solar rail (about 1m, I think)
- an adjustable antenna mount (typically used with Yagi antennas)
- PVC irrigation pipe as the mast, far lighter than steel and more than strong enough
- guy wires, tensioners and anchors
Solar rail across the ridge, adjustable antenna mount, irrigation-pipe mast.
Dealing with raw weather station entities
BTHome hands over about a dozen entities: temperature, humidity, dew point, pressure, sustained wind, wind gust, wind direction, precipitation total, illuminance, UV, moisture, capacitor voltage and battery charge.
But, an instantaneous wind sample is not a wind speed. Absolute pressure is not the pressure you’ll find in weather reports. A rain total from a piezo cannot be considered accurate until you have checked it against a gauge. Nobody reads 59,276 lux and intuitively knows what that means – for humans.
What I wanted to add or create were meaningful conditions, interpreted based on the data.
Wind
My first version put a lowpass filter (EMA) on both the sustained and gust wind channels. That was measurably wrong.
A sustained wind is, by WMO convention, a 10-minute mean. A gust is the peak within that period. An exponential moving average is a jumpy estimator of the first and plainly the wrong thing for the second: against one day’s data, it threw away 36% of the gust peak, reporting 16.8 km/h where the raw channel peaked at 26.3 km/h.
Both channels now use statistics on the raw entity, with the statistic that matches the channel’s definition:
- platform: statistics
name: "Wind Speed Filtered"
entity_id: sensor.weather_station_wind_speed_sustained
# a sustained wind is a mean
state_characteristic: mean
sampling_size: 200
max_age:
minutes: 10
keep_last_sample: true
- platform: statistics
name: "Wind Gust Filtered"
entity_id: sensor.weather_station_wind_speed_gust
# a gust is a peak
state_characteristic: value_max
sampling_size: 200
max_age:
minutes: 10
keep_last_sample: true
Three things worth calling out:
- Read the raw entity: The WS90 already reduces its own ~8.8 s window twice, into a mean and a max. Filtering first and aggregating second attenuates the signal twice.
sampling_sizemust exceed the samples in the window, ormax_ageis never reached and you silently get a rolling-N window instead of a rolling-time one.keep_last_sample: truestops dead calm reading as a fault and the sensor going unknown. The station only emits a state change when the value changes, so a stationary0.0adds no samples. On one calm day the 10-minute window emptied six times and the sensor wentunknown. This would affect my other sensors that needed it (the Beaufort scale for wind, compass direction and fog probability, among others).
Wind direction: you cannot average degrees
Mathematically this is important, since you cannot linearly average a circular/cyclical quantity.
The fix is the circular mean: treat each bearing as a unit vector, average the vectors, then read the resulting angle back off. In practice that means decomposing to sine and cosine, filtering those independently, and recombining with them:
- sensor:
- name: "Wind Direction Sin"
state: "{{ sin(states('sensor.weather_station_wind_direction') | float(0) * pi / 180) }}"
unit_of_measurement: "1"
- name: "Wind Direction Cos"
state: "{{ cos(states('sensor.weather_station_wind_direction') | float(0) * pi / 180) }}"
unit_of_measurement: "1"
# ... each of those gets its own lowpass filter, then:
- name: "Wind Direction Filtered"
state: >-
{% set s = states('sensor.wind_direction_sin_filtered') | float(0) %}
{% set c = states('sensor.wind_direction_cos_filtered') | float(0) %}
{{ (atan2(s, c) * 180 / pi) % 360 }}
Here the EMA works fine for the filtered sensor to remove anomalies.
There is also an interesting side effect: the magnitude of the averaged vector, which I do not currently use, is a measure of how steady the direction has been.
The Beaufort condition sensor sits on top of the filtered speed, using the standard scale, and the compass sensor deliberately reports nothing below 0.5 km/h, because a direction is meaningless in a dead calm.
Rain and the hardware problem I have not solved yet
Piezo gauges detect drop impacts, so their scale factor is not constant: it varies with drop size and intensity. They need calibrating against a real gauge, and this part is still very much a work in progress.
I built a tiered-gain sensor for it: raw deltas multiplied by a gain chosen by rate tier (this is similar what you’d get with the same WS90 when used with Ecowitt’s bridge and software), with the tiers on the standard rain intensity bands. The structure was fine, but he data was not:
| Event | Implied gain |
|---|---|
| Three light-rain events, a tick or two each | 2.70×, 4.32×, 2.00× |
| Whole event, 1.6 mm raw vs 2.0 mm gauge | 0.44× |
| Whole event, 1.2 mm raw vs 1.0 mm gauge | 0.30× |
The light readings are dominated by the gauge’s own 0.1 mm resolution. Two of my trustworthy whole-event readings differ by a third. Two events cannot separate two tiers anyway: fitting two parameters to two measurements gives an exact solution with zero residual, so nothing is left over to tell you whether the fit means anything.
So, I reset every gain to 1.0 (for now). The sensor passes raw through unchanged for now, and the config defines a measurement protocol instead of a number. When to empty the gauge, note both times, pull the raw entity for exactly that window, and prefer an event that sits in one rate tier.
Air pressure
The station reports absolute station pressure, QFE. Forecasts and synoptic charts provide a sea-level-adjusted pressure, QNH. Interestingly, when I recently went flying with my friend, we talked about this – for those of you that are pilots (I’m not; my friend is).
At my 130 m elevation the offset is about 15 hPa. In the screenshot further down, the station reads 1 000.4 while the adjusted value is 1 015.4. Neither number is wrong, but only the second can be compared with anything else. Taken at face value, the raw one reads like a modest low when conditions are entirely ordinary.
- name: "Air Pressure (QNH)"
state: >-
{% set P_station = states(air_pressure_entity) | float(0) %}
{% set g = 9.80665 %}
{% set h = 130 %}
{# R is the specific gas constant for dry air #}
{% set R = 287.05 %}
{% set T = states(temperature_entity) | float(15) + 273.15 %}
{{ (P_station * (e ** (g * h / (R * T)))) | round(1) }}
What actually forecasts weather is not the value but the tendency, so a change sensor feeds a seven-tier trend from rising rapidly to falling rapidly. The window is three hours because that is how pressure tendency is defined, not because it looked nice on a graph. I show both QFE and QNH on the dashboard: what the instrument reads, and what it means.
Fog: inferring something the station cannot measure
We get proper morning fog, and forecast providers are useless about it at this scale. But the station has the ingredients: fog forms when the air is at saturation and there is too little wind to mix it away.
- name: "Fog Probability"
unit_of_measurement: "%"
state: >-
{% set spread = states(spread_entity) | float(none) %}
{% set wind = states(wind_entity) | float(none) %}
{% if spread is none or wind is none %}
{{ none }}
{% elif is_state(rain_entity, 'on') or is_state(moisture_entity, 'off') %}
{{ 0 }}
{% else %}
{% set spread_score = [[((2.5 - spread) / 2.0), 0] | max, 1] | min %}
{% set wind_score = [[((12 - wind) / 9), 0] | max, 1] | min %}
{{ (spread_score * wind_score * 100) | round(0) }}
{% endif %}
Dew point spread and wind each score 0-1 and multiply. Each flatlines at 1 below a floor, because 0.5°C of spread is within sensor noise of saturation and 3 km/h still supports radiation fog, so ideal-but-nonzero inputs are not penalised.
Two things directly sets it to zero. Rain, because it drives the spread to nothing without being fog (steady light rain otherwise scored ~80–100%). An explicitly dry moisture sensor, because real fog should always wet the top plate. That test is for an explicit off rather than “not on”, so a sensor that goes unavailable leaves the estimate alone instead of silently pinning it to zero.
Using that no-rain pre-condition produced a bug when it drizzled. A piezo only registers while drops are landing, so the rain sensor flickered off during the gaps while the air stayed saturated. In one ten-hour spell of spitting rain it toggled more than 10 times. A held copy of the sensor with delay_off: "00:30:00" fixed it.
The binary sensor on top adds delay_on: "00:15:00". Above 85% is fog and 80–85% is possible fog. Why 85 and not 50? Because, apparently, dew and fog are the same condition at the sensor (saturated air and no wind). The plateaus make high values common on merely dewy calm nights, around 85% observed, while genuine fog scored more than 88% using the same data. The bar for confidently calling it fog has to sit above the dewy band. A chance attribute exposes the three-way answer (present, possible, none).
This one took several rounds of calibration against real mornings, and it now works well.
Adding meaning to numbers
My interpreted sensors broadly have the same structure: a state value, a label for that valud for display and some raw values as attributes. I did it so that I could pass one entity to each tile card.
I defined:
- Beaufort wind condition
- Compass wind direction
- Sun lux condition
- UV risk bands
- Rain rate condition
- Humidity perception
- Thermal perception
The scale definitions are Jinja macros, so the Beaufort table exists once and is imported by the sensor, the dashboard and the morning brief.
Two of these were more than table lookups.
Thermal perception is fed apparent temperature rather than dry-bulb, using the Steadman formula the Australian BoM publishes, because at the cold end the body loses heat convectively: a 12°C day in a 25 km/h southeaster feels like 6°C. That sensor reminded me how important it is to implement availability for sensors:
availability: >-
{{ has_value('sensor.weather_station_temperature')
and has_value('sensor.weather_station_humidity')
and has_value('sensor.wind_speed_filtered') }}
Every input in the formula defaults to 0, so one missing input could yield a plausible-looking −4.0°C rather than an error, which then became “Freezing”. Plausible wrong values are potentially more dangerous than errors. If an input is required, make it a hard requirement.
Humidity needed splitting into two axes. Dew point perception measures mugginess; relative humidity measures nearness to saturation. They agree when it is warm and contradict on a cold saturated morning, where the dew point correctly says “dry” while 97% RH and visible fog say otherwise. I defined my own override here to add “damp” and leave the muggy end alone, so heat-health signals are never masked. I couldn’t find formal definitions that gave me a single, continuous scale.
The morning weather brief
A little after our wake-up time, and if I am home, I get a push notification as a brief weather report, for example:
Weather Brief
Feels cold and damp at 9°C now, with fog. Calm right now. Partly cloudy later, high of 19°C. A gentle breeze from the Northwest (14 km/h) this afternoon.
I made heavy use of Claude to build this large template. I’m not going to bore you with exactly how this works. It’s fiddly code (phrase tables, article selection, clause ordering) and handing the whole job to an LLM is the obvious next move, and I intend to try it at some point. Going old-school for now.
I did find an oddity though regarding wind conditions, so I built two sensors benchmarking the station against the forecast, logged with direction so the deficit can be binned by quadrant:
| Channel | Station vs forecast |
|---|---|
| Sustained wind | ~41% |
| Gusts | ~105% |
This is apparently called site shelter: gusts penetrate gaps in the trees while the steady flow gets combed out. It is not a fault and it needs no correction, because we are measuring the conditions at a specific site, but issue comes in where the forecast describes open-terrain 10 m wind across a larger area. It is tempting to see 41% and reach for a correction factor, but agreement with a forecast is not accuracy in this case. Scaling up to match would report a wind that does not exist where I live.
Some other things I use my weather data for:
- Rain expected: Four time slots per day to allow for outside chores to be done before the rain arrives
- High UV: Above index 7, and only when it is not raining.
- Irrigation skip: Yesterday’s measured rain plus today’s and tomorrow’s forecast, skipping above 5 mm, with a notification explaining the three numbers.
- Low light: Measured illuminance below 35 klux combined with a fair-weather check. Some of our lights and cameras rely on this.
- Climate control: Comparing inside and outside temperatures trends and temperature perceptions to decide when to turn on the AC for heating or cooling, and when to turn it off.
The dashboard
Eight standard tile cards. The whole design principle is to provide the human label first and numbers second: “Slightly Warm · 26.1 °C” and not just “26.1 °C”.
- type: tile
entity: sensor.wind_condition_beaufort
name: Wind
state_content:
- label
- wind_speed_with_unit
- compass_direction
tap_action:
action: more-info
entity: sensor.wind_speed_filtered_km_h
Note tap_action pointing elsewhere: the tile displays the interpreted condition, but tapping it opens the history of the underlying measurement, which is what you actually want to look at.
Icon colours come from a shared gradient macro library, so temperature, UV, wind, pressure and rain rate all colour consistently. Rain rate and illuminance use a log10 scale, since both span four orders of magnitude and a linear gradient wastes its whole range on the top decade. The uix: blocks you will see in my YAML are card customisations.
Behind the tiles, the detail lives in fold-out rows.
The config
- Weather package (every sensor and automation above):
home_assistant/packages/weather.yaml - Dashboard view:
home_assistant/ui-lovelace/environment.yaml - Jinja macros (Beaufort, compass, rate, gains, thermal tiers):
home_assistant/custom_templates/environment.jinja - Colour gradients:
home_assistant/custom_templates/dynamic_colors.jinja(also see my project page about this)
What is next
- Finish calibrating the rain gauge, with an event that sits in one rate tier.
- Publish to Weather Underground (but perhaps not until they provide data back for free for contributors).
- Rewrite the morning brief’s prose generation with an LLM instead of Jinja phrase tables.



