[fineoffsetweatherstation] Add support for PM1 and PM4 air quality measurements and battery level for WH46 (#20483)

Signed-off-by: Andreas Berger <andreas@berger-freelancer.com>
This commit is contained in:
Andreas Berger
2026-04-03 14:24:04 +02:00
committed by GitHub
parent d7083c219c
commit e38efe4b2f
7 changed files with 110 additions and 6 deletions
@@ -19,7 +19,7 @@ Here is a product picture of how this Weather Station looks like:
![WH2650](doc/WH2650.png)
This binding works offline by [implementing the wire protocol](https://community.openhab.org/uploads/short-url/cuV8oOaCYHZhdm0hVJUN7hxMMfe.pdf) of the WiFi gateway device.
This binding works offline by [implementing the wire protocol](https://oss.ecowitt.net/uploads/20260112/TCP%20API%20Interface%20Communication%20Protocol%20V1.7.0.pdf) of the WiFi gateway device.
## Discussion
@@ -27,7 +27,7 @@ If you have any issues or feedback, please feel free to [get in touch via the co
## Supported Things
- `weatherstation`: A Fine Offset gateway device with the ThingTypeUID `fineoffsetweatherstation:weatherstation` which supports the [wire protocol](https://community.openhab.org/uploads/short-url/cuV8oOaCYHZhdm0hVJUN7hxMMfe.pdf) e.g.:
- `weatherstation`: A Fine Offset gateway device with the ThingTypeUID `fineoffsetweatherstation:weatherstation` which supports the [wire protocol](https://oss.ecowitt.net/uploads/20260112/TCP%20API%20Interface%20Communication%20Protocol%20V1.7.0.pdf) e.g.:
- HP2550
- HP3500
- GW1000
@@ -60,6 +60,7 @@ If you have any issues or feedback, please feel free to [get in touch via the co
- WH40 - Rainfall sensor
- WH41 - Outdoor air quality sensor
- WH45 - Air quality sensor
- WH46D - 7-in-1 Indoor Air Quality Monitor
- WH51 - Soil moisture sensor
- WH55 - Water leak detection sensor
- WH57 - Lightning detection sensor
@@ -99,9 +100,9 @@ This binding support discovery of Fine Offset gateway devices by sending a broad
### `sensor` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------|---------|----------|----------|
| sensor | text | The name of sensor attached to the gateway (multiple sensors of the same type may have different names according to the bound channel) | N/A | yes | no |
| Name | Type | Description | Default | Required | Advanced |
|--------|------|----------------------------------------------------------------------------------------------------------------------------------------|---------|----------|----------|
| sensor | text | The name of sensor attached to the gateway (multiple sensors of the same type may have different names according to the bound channel) | N/A | yes | no |
Valid sensors:
@@ -259,12 +260,17 @@ Valid sensors:
| temperature-external-channel-8 | Number:Temperature | R | External Temperature Sensor Channel 8 |
| sensor-co2-temperature | Number:Temperature | R | Temperature (CO2-Sensor) |
| sensor-co2-humidity | Number:Dimensionless | R | Humidity (CO2-Sensor) |
| sensor-co2-pm1 | Number:Density | R | PM1 Air Quality (CO2-Sensor) |
| sensor-co2-pm1-24-hour-average | Number:Density | R | PM1 Air Quality 24 Hour Average (CO2-Sensor) |
| sensor-co2-pm4 | Number:Density | R | PM4 Air Quality (CO2-Sensor) |
| sensor-co2-pm4-24-hour-average | Number:Density | R | PM4 Air Quality 24 Hour Average (CO2-Sensor) |
| sensor-co2-pm10 | Number:Density | R | PM10 Air Quality (CO2-Sensor) |
| sensor-co2-pm10-24-hour-average | Number:Density | R | PM10 Air Quality 24 Hour Average (CO2-Sensor) |
| sensor-co2-pm25 | Number:Density | R | PM2.5 Air Quality (CO2-Sensor) |
| sensor-co2-pm25-24-hour-average | Number:Density | R | PM2.5 Air Quality 24 Hour Average (CO2-Sensor) |
| sensor-co2-co2 | Number:Dimensionless | R | CO2 |
| sensor-co2-co2-24-hour-average | Number:Dimensionless | R | CO2 24 Hour Average |
| sensor-co2-wh46-battery-level | Number:Dimensionless | R | Battery status WH46 |
| leaf-wetness-channel-1 | Number:Dimensionless | R | Leaf Moisture Channel 1 |
| leaf-wetness-channel-2 | Number:Dimensionless | R | Leaf Moisture Channel 2 |
| leaf-wetness-channel-3 | Number:Dimensionless | R | Leaf Moisture Channel 3 |
@@ -43,7 +43,9 @@ public class FineOffsetWeatherStationBindingConstants {
public static final ChannelTypeUID CHANNEL_TYPE_UV_INDEX = new ChannelTypeUID(BINDING_ID, "uv-index");
public static final ChannelTypeUID CHANNEL_TYPE_UV_RADIATION = new ChannelTypeUID(BINDING_ID, "uv-radiation");
public static final ChannelTypeUID CHANNEL_TYPE_RAIN_RATE = new ChannelTypeUID(BINDING_ID, "rain-rate");
public static final ChannelTypeUID CHANNEL_TYPE_PM1 = new ChannelTypeUID(BINDING_ID, "pm1");
public static final ChannelTypeUID CHANNEL_TYPE_PM25 = new ChannelTypeUID(BINDING_ID, "pm25");
public static final ChannelTypeUID CHANNEL_TYPE_PM4 = new ChannelTypeUID(BINDING_ID, "pm4");
public static final ChannelTypeUID CHANNEL_TYPE_PM10 = new ChannelTypeUID(BINDING_ID, "pm10");
public static final ChannelTypeUID CHANNEL_TYPE_CO2 = new ChannelTypeUID(BINDING_ID, "co2");
public static final ChannelTypeUID CHANNEL_TYPE_WATER_LEAK_DETECTION = new ChannelTypeUID(BINDING_ID,
@@ -148,6 +148,27 @@ public enum Measurand {
// This is for heap : the available stack top. If it is reducing, it means the stack is using up.
ITEM_HEAP_FREE("free-heap-size", 0x6c, "Free Heap Size", MeasureType.MEMORY, CHANNEL_TYPE_FREE_HEAP_SIZE),
ITEM_SENSOR_CO2_WH46(0x6B,
new MeasurandParser("sensor-co2-temperature", "Temperature (CO₂-Sensor)", MeasureType.TEMPERATURE),
new MeasurandParser("sensor-co2-humidity", "Humidity (CO₂-Sensor)", MeasureType.PERCENTAGE),
new MeasurandParser("sensor-co2-pm10", "PM10 Air Quality (CO₂-Sensor)", MeasureType.PM10),
new MeasurandParser("sensor-co2-pm10-24-hour-average", "PM10 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM10),
new MeasurandParser("sensor-co2-pm25", "PM2.5 Air Quality (CO₂-Sensor)", MeasureType.PM25),
new MeasurandParser("sensor-co2-pm25-24-hour-average", "PM2.5 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM25),
new MeasurandParser("sensor-co2-co2", "CO₂", MeasureType.CO2),
new MeasurandParser("sensor-co2-co2-24-hour-average", "CO₂ 24 hour average", MeasureType.CO2),
// the battery status can only be retrieved here and not via Command.CMD_READ_SENSOR_ID_NEW, since WH46
// is not listed in the sensor array
new MeasurandParser("sensor-co2-wh46-battery-level", "Battery Level WH46", MeasureType.BATTERY_LEVEL),
new MeasurandParser("sensor-co2-pm1", "PM1 Air Quality (CO₂-Sensor)", MeasureType.PM1),
new MeasurandParser("sensor-co2-pm1-24-hour-average", "PM1 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM1),
new MeasurandParser("sensor-co2-pm4", "PM4 Air Quality (CO₂-Sensor)", MeasureType.PM4),
new MeasurandParser("sensor-co2-pm4-24-hour-average", "PM4 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM4)),
ITEM_SENSOR_CO2(0x70,
new MeasurandParser("sensor-co2-temperature", "Temperature (CO₂-Sensor)", MeasureType.TEMPERATURE),
new MeasurandParser("sensor-co2-humidity", "Humidity (CO₂-Sensor)", MeasureType.PERCENTAGE),
@@ -21,8 +21,10 @@ import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWe
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_LIGHTNING_COUNTER;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_LIGHTNING_DISTANCE;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_LIGHTNING_TIME;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_PM1;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_PM10;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_PM25;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_PM4;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_PRESSURE;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_RAIN;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_RAIN_RATE;
@@ -43,6 +45,7 @@ import static org.openhab.core.library.unit.Units.MICROGRAM_PER_CUBICMETRE;
import static org.openhab.core.library.unit.Units.MILLIMETRE_PER_HOUR;
import static org.openhab.core.library.unit.Units.PARTS_PER_MILLION;
import static org.openhab.core.library.unit.Units.PERCENT;
import static org.openhab.core.thing.DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_BATTERY_LEVEL;
import java.time.Instant;
import java.util.function.BiFunction;
@@ -52,6 +55,7 @@ import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.Utils;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.BatteryStatus;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
@@ -89,8 +93,12 @@ public enum MeasureType {
LUX(Units.LUX, 4, CHANNEL_TYPE_ILLUMINATION, (data, offset) -> toUInt32(data, offset) / 10.),
PM1(MICROGRAM_PER_CUBICMETRE, 2, CHANNEL_TYPE_PM1, (data, offset) -> toUInt16(data, offset) / 10.),
PM25(MICROGRAM_PER_CUBICMETRE, 2, CHANNEL_TYPE_PM25, (data, offset) -> toUInt16(data, offset) / 10.),
PM4(MICROGRAM_PER_CUBICMETRE, 2, CHANNEL_TYPE_PM4, (data, offset) -> toUInt16(data, offset) / 10.),
PM10(MICROGRAM_PER_CUBICMETRE, 2, CHANNEL_TYPE_PM10, (data, offset) -> toUInt16(data, offset) / 10.),
CO2(PARTS_PER_MILLION, 2, CHANNEL_TYPE_CO2, Utils::toUInt16),
@@ -122,7 +130,13 @@ public enum MeasureType {
BYTE(1, null, (data, offset) -> new DecimalType(toUInt8(data[offset]))),
MEMORY(Units.BYTE, 4, null, Utils::toUInt32),
DATE_TIME2(6, null, (data, offset) -> new DateTimeType(Instant.ofEpochSecond(toUInt32(data, offset))));
DATE_TIME2(6, null, (data, offset) -> new DateTimeType(Instant.ofEpochSecond(toUInt32(data, offset)))),
BATTERY_LEVEL(1, SYSTEM_CHANNEL_TYPE_UID_BATTERY_LEVEL, (data, offset) -> {
@Nullable
Integer level = new BatteryStatus(BatteryStatus.Type.LEVEL, data[offset]).getPercentage();
return level == null ? null : new QuantityType<>(level, PERCENT);
});
private final int byteSize;
private final @Nullable ChannelTypeUID channelTypeUID;
@@ -39,6 +39,8 @@ channel-type.fineoffsetweatherstation.lightning-distance.label = Lightning Dista
channel-type.fineoffsetweatherstation.lightning-time.label = Lightning Time
channel-type.fineoffsetweatherstation.max-wind-speed.label = Maximum Wind Speed
channel-type.fineoffsetweatherstation.moisture.label = Moisture
channel-type.fineoffsetweatherstation.pm1.label = PM1 Air Quality
channel-type.fineoffsetweatherstation.pm4.label = PM4 Air Quality
channel-type.fineoffsetweatherstation.pm10.label = PM10 Air Quality
channel-type.fineoffsetweatherstation.pm25.label = PM2.5 Air Quality
channel-type.fineoffsetweatherstation.pressure.label = Pressure
@@ -124,12 +126,17 @@ gateway.dynamic-channel.lightning-counter.label = Lightning Strikes Today
gateway.dynamic-channel.temperature-external-channel.label = External Temperature Sensor Channel {0}
gateway.dynamic-channel.sensor-co2-temperature.label = Temperature (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-humidity.label = Humidity (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm1.label = PM1 Air Quality (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm1-24-hour-average.label = PM1 Air Quality 24 Hour Average (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm4.label = PM4 Air Quality (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm4-24-hour-average.label = PM4 Air Quality 24 Hour Average (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm10.label = PM10 Air Quality (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm10-24-hour-average.label = PM10 Air Quality 24 Hour Average (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm25.label = PM2.5 Air Quality (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-pm25-24-hour-average.label = PM2.5 Air Quality 24 Hour Average (CO2-Sensor)
gateway.dynamic-channel.sensor-co2-co2.label = CO2
gateway.dynamic-channel.sensor-co2-co2-24-hour-average.label = CO2 24 Hour Average
gateway.dynamic-channel.sensor-co2-wh46-battery-level.label = Battery Level WH46
gateway.dynamic-channel.leaf-wetness-channel.label = Leaf Moisture Channel {0}
gateway.dynamic-channel.piezo-rain-rate.label = Rain Rate
gateway.dynamic-channel.piezo-rain-event.label = Rain Event
@@ -145,6 +145,29 @@
<state pattern="%.1f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="pm4">
<item-type>Number:Density</item-type>
<label>PM4 Air Quality</label>
<category>AirQuality</category>
<tags>
<tag>Measurement</tag>
<tag>ParticulateMatter</tag>
</tags>
<state pattern="%.1f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="pm1">
<item-type>Number:Density</item-type>
<label>PM1 Air Quality</label>
<category>AirQuality</category>
<tags>
<tag>Measurement</tag>
<tag>ParticulateMatter</tag>
</tags>
<state pattern="%.1f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="co2">
<item-type>Number:Dimensionless</item-type>
<label>CO₂</label>
@@ -188,6 +188,37 @@ class FineOffsetDataParserTest {
new Tuple("piezo-rain-year", "19.9 mm"), new Tuple("piezo-rain-event", "0 mm"));
}
@Test
void testLiveDataWH46() {
byte[] data = HexUtils.hexToBytes(
"FFFF27005E0100E6063008270E09270E02005D07550A00B00B00080C000A150000F596160030170019001A0E0000100000110000120000000013000000000D00006B00DE34001300100013001002D302DE0112000E00130010056C00014F08D0");
DebugDetails debugDetails = new DebugDetails(data, Command.CMD_WS980_LIVEDATA, Protocol.DEFAULT);
List<MeasuredValue> measuredValues = new FineOffsetDataParser(Protocol.DEFAULT).getMeasuredValues(data,
debugDetails);
Assertions.assertThat(measuredValues)
.extracting(MeasuredValue::getChannelId, measuredValue -> measuredValue.getState().toString())
.containsExactly(new Tuple("temperature-indoor", "23 °C"), new Tuple("humidity-indoor", "48 %"),
new Tuple("pressure-absolute", "999.8 hPa"), new Tuple("pressure-relative", "999.8 hPa"),
new Tuple("temperature-outdoor", "9.3 °C"), new Tuple("humidity-outdoor", "85 %"),
new Tuple("direction-wind", "176 °"), new Tuple("speed-wind", "0.8 m/s"),
new Tuple("speed-gust", "1 m/s"), new Tuple("illumination", "6287 lx"),
new Tuple("irradiation-uv", "4.8 mW/m²"), new Tuple("uv-index", "0"),
new Tuple("wind-max-day", "2.6 m/s"), new Tuple("rain-rate", "0 mm/h"),
new Tuple("rain-day", "0 mm"), new Tuple("rain-week", "0 mm"), new Tuple("rain-month", "0 mm"),
new Tuple("rain-year", "0 mm"), new Tuple("rain-event", "0 mm"),
new Tuple("sensor-co2-temperature", "22.2 °C"), new Tuple("sensor-co2-humidity", "52 %"),
new Tuple("sensor-co2-pm10", "1.9 µg/m³"),
new Tuple("sensor-co2-pm10-24-hour-average", "1.6 µg/m³"),
new Tuple("sensor-co2-pm25", "1.9 µg/m³"),
new Tuple("sensor-co2-pm25-24-hour-average", "1.6 µg/m³"),
new Tuple("sensor-co2-co2", "723 ppm"), new Tuple("sensor-co2-co2-24-hour-average", "734 ppm"),
new Tuple("sensor-co2-wh46-battery-level", "20 %"), new Tuple("sensor-co2-pm1", "460.8 µg/m³"),
new Tuple("sensor-co2-pm1-24-hour-average", "358.4 µg/m³"),
new Tuple("sensor-co2-pm4", "486.4 µg/m³"),
new Tuple("sensor-co2-pm4-24-hour-average", "410.1 µg/m³"),
new Tuple("free-heap-size", "85768 B"));
}
@Test
void testFirmware() {
byte[] data = HexUtils.hexToBytes("FFFF501511456173795765617468657256312E362E3400");