[fineoffsetweatherstation] Replace Measurand enum with a MeasurandRegistry built from a fluent DSL (#21003)

* [fineoffsetweatherstation] Extract ParserCustomizationType to top level

Signed-off-by: Andreas Berger <andreas@berger-freelancer.com>
This commit is contained in:
Andreas Berger
2026-06-19 15:26:50 +02:00
committed by GitHub
parent a4bef92da4
commit a13e2c6b69
14 changed files with 917 additions and 595 deletions
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
/**
* What a TCP item code resolves to: a {@link Parser} (a single {@link Measurand}, a
* {@link MeasurandGroup}, or a {@link Skip}) plus the channel for multi-channel measurands.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public class CodeBinding {
private final Parser parser;
private final @Nullable Integer channel;
private final String debugName;
CodeBinding(Parser parser, @Nullable Integer channel, String debugName) {
this.parser = parser;
this.channel = channel;
this.debugName = debugName;
}
public int extractMeasuredValues(byte[] data, int offset, @Nullable ParserCustomizationType customizationType,
List<MeasuredValue> result, DebugDetails debugDetails) {
return parser.extractMeasuredValues(data, offset, channel, customizationType, result, debugDetails);
}
public String getDebugString() {
return debugName + (channel == null ? "" : " channel " + channel);
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
/**
* Binds an HTTP {@code get_livedata_info} entry to a measurand's parser. The optional {@code alternate} is used
* when the reported unit does not fit the primary parser's canonical dimension - this lets the lux illumination
* and the W/m² solar-radiation channel share the item code {@code 0x15}.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public final class HttpBinding {
private final Measurand parser;
private @Nullable Measurand alternate;
HttpBinding(Measurand parser, @Nullable Measurand alternate) {
this.parser = parser;
this.alternate = alternate;
}
void setAlternate(Measurand alternate) {
this.alternate = alternate;
}
public @Nullable MeasuredValue parse(String val, @Nullable String unit, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType) {
MeasuredValue value = parser.parseHttp(val, unit, channel, customizationType);
@Nullable
Measurand alternate = this.alternate;
if (value == null && alternate != null) {
value = alternate.parseHttp(val, unit, channel, customizationType);
}
return value;
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Declares where a measurand's value is found in the Ecowitt HTTP {@code get_livedata_info} response,
* attached to a measurand through its {@code http} / {@code httpAlt} fluent methods.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
final class HttpSource {
private final HttpGroup group;
// explicit HTTP item code (used when it differs from the TCP code, e.g. the piezo rain channels)
private final @Nullable Integer httpCode;
// string id (e.g. "srain_piezo") or named field (e.g. "intemp"); null means "reuse the TCP item code"
private final @Nullable String key;
// whether this measurand is the dimension alternate for an already-registered code (see SOLAR_RADIATION)
private final boolean alternate;
HttpSource(HttpGroup group, @Nullable Integer httpCode, @Nullable String key, boolean alternate) {
this.group = group;
this.httpCode = httpCode;
this.key = key;
this.alternate = alternate;
}
/**
* @return the lookup key: the explicit string id/field, the explicit HTTP item code, or - as a fallback for a
* measurand declaring {@code http(group)} - its single TCP item code
*/
String resolveKey(@Nullable Integer tcpCode) {
String explicitKey = key;
if (explicitKey != null) {
return explicitKey;
}
Integer explicitCode = httpCode;
if (explicitCode != null) {
return codeKey(explicitCode);
}
return codeKey(Objects.requireNonNull(tcpCode));
}
HttpGroup getGroup() {
return group;
}
boolean isAlternate() {
return alternate;
}
private static String codeKey(int code) {
return "0x" + Integer.toHexString(code & 0xFF);
}
}
@@ -12,536 +12,82 @@
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_FREE_HEAP_SIZE;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_MAX_WIND_SPEED;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_MOISTURE;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_UV_INDEX;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.State;
/**
* The measurands of supported by the gateway.
* Parses a single measurand from a TCP payload or an HTTP {@code get_livedata_info} response.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public enum Measurand {
INTEMP("temperature-indoor", 0x01, "Indoor Temperature", MeasureType.TEMPERATURE,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_INDOOR_TEMPERATURE,
http(HttpGroup.WH25, "intemp")),
OUTTEMP("temperature-outdoor", 0x02, "Outdoor Temperature", MeasureType.TEMPERATURE,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_OUTDOOR_TEMPERATURE, http(HttpGroup.COMMON_LIST)),
DEWPOINT("temperature-dew-point", 0x03, "Dew point", MeasureType.TEMPERATURE, http(HttpGroup.COMMON_LIST)),
WINDCHILL("temperature-wind-chill", 0x04, "Wind chill", MeasureType.TEMPERATURE, http(HttpGroup.COMMON_LIST)),
HEATINDEX("temperature-heat-index", 0x05, "Heat index", MeasureType.TEMPERATURE, http(HttpGroup.COMMON_LIST)),
INHUMI("humidity-indoor", 0x06, "Indoor Humidity", MeasureType.PERCENTAGE, http(HttpGroup.WH25, "inhumi")),
OUTHUMI("humidity-outdoor", 0x07, "Outdoor Humidity", MeasureType.PERCENTAGE,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_ATMOSPHERIC_HUMIDITY, http(HttpGroup.COMMON_LIST)),
ABSBARO("pressure-absolute", 0x08, "Absolutely pressure", MeasureType.PRESSURE, http(HttpGroup.WH25, "abs")),
RELBARO("pressure-relative", 0x09, "Relative pressure", MeasureType.PRESSURE,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_BAROMETRIC_PRESSURE, http(HttpGroup.WH25, "rel")),
WINDDIRECTION("direction-wind", 0x0A, "Wind Direction", MeasureType.DEGREE,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_DIRECTION, http(HttpGroup.COMMON_LIST)),
WINDSPEED("speed-wind", 0x0B, "Wind Speed", MeasureType.SPEED,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_SPEED, http(HttpGroup.COMMON_LIST)),
GUSTSPEED("speed-gust", 0x0C, "Gust Speed", MeasureType.SPEED,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_SPEED, http(HttpGroup.COMMON_LIST)),
RAINEVENT("rain-event", 0x0D, "Rain Event", MeasureType.HEIGHT, http(HttpGroup.RAIN),
new ParserCustomization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG)),
RAINRATE("rain-rate", 0x0E, "Rain Rate", MeasureType.HEIGHT_PER_HOUR, http(HttpGroup.RAIN),
new ParserCustomization(ParserCustomizationType.ELV, MeasureType.HEIGHT_PER_HOUR_BIG)),
RAINHOUR("rain-hour", 0x0F, "Rain hour", MeasureType.HEIGHT, http(HttpGroup.RAIN),
new ParserCustomization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG)),
RAINDAY("rain-day", 0x10, "Rain Day", MeasureType.HEIGHT, http(HttpGroup.RAIN),
new ParserCustomization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG),
new ParserCustomization(ParserCustomizationType.RAIN_READING, MeasureType.HEIGHT_BIG)),
RAINWEEK("rain-week", 0x11, "Rain Week", MeasureType.HEIGHT, http(HttpGroup.RAIN),
new ParserCustomization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG),
new ParserCustomization(ParserCustomizationType.RAIN_READING, MeasureType.HEIGHT_BIG)),
RAINMONTH("rain-month", 0x12, "Rain Month", MeasureType.HEIGHT_BIG, http(HttpGroup.RAIN)),
RAINYEAR("rain-year", 0x13, "Rain Year", MeasureType.HEIGHT_BIG, http(HttpGroup.RAIN)),
RAINTOTALS("rain-total", 0x14, "Rain Totals", MeasureType.HEIGHT_BIG, http(HttpGroup.RAIN)),
// 0x15 is reported as lux illumination or as solar radiation (W/m²); see SOLAR_RADIATION below
LIGHT("illumination", 0x15, "Light", MeasureType.LUX, http(HttpGroup.COMMON_LIST)),
UV("irradiation-uv", 0x16, "UV", MeasureType.MILLIWATT_PER_SQUARE_METRE, http(HttpGroup.COMMON_LIST)),
UVI("uv-index", 0x17, "UV index", MeasureType.BYTE, CHANNEL_TYPE_UV_INDEX, http(HttpGroup.COMMON_LIST)),
TIME("time", 0x18, "Date and time", MeasureType.DATE_TIME2),
DAYLWINDMAX("wind-max-day", 0X19, "Day max wind", MeasureType.SPEED, CHANNEL_TYPE_MAX_WIND_SPEED,
http(HttpGroup.COMMON_LIST)),
TEMPX("temperature-channel", new int[] { 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21 }, "Temperature",
MeasureType.TEMPERATURE, http(HttpGroup.CH_AISLE, "temp")),
HUMIX("humidity-channel", new int[] { 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29 }, "Humidity",
MeasureType.PERCENTAGE, http(HttpGroup.CH_AISLE, "humidity")),
SOILTEMPX("temperature-soil-channel",
new int[] { 0x2B, 0x2D, 0x2F, 0x31, 0x33, 0x35, 0x37, 0x39, 0x3B, 0x3D, 0x3F, 0x41, 0x43, 0x45, 0x47,
0x49 },
"Soil Temperature", MeasureType.TEMPERATURE),
SOILMOISTUREX("moisture-soil-channel",
new int[] { 0x2C, 0x2E, 0x30, 0x32, 0x34, 0x36, 0x38, 0x3A, 0x3C, 0x3E, 0x40, 0x42, 0x44, 0x46, 0x48,
0x4A },
"Soil Moisture", MeasureType.PERCENTAGE, CHANNEL_TYPE_MOISTURE, http(HttpGroup.CH_SOIL, "humidity")),
// will no longer be used
// skip battery-level, since it is read via Command.CMD_READ_SENSOR_ID_NEW
LOWBATT(0x4C, new Skip(1)),
PM25_24HAVGX("air-quality-24-hour-average-channel", new int[] { 0x4D, 0x4E, 0x4F, 0x50 },
"PM2.5 Air Quality 24 hour average", MeasureType.PM25),
PM25_CHX("air-quality-channel", new int[] { 0x2A, 0x51, 0x52, 0x53 }, "PM2.5 Air Quality", MeasureType.PM25,
http(HttpGroup.CH_PM25, "PM25")),
LEAK_CHX("water-leak-channel", new int[] { 0x58, 0x59, 0x5A, 0x5B }, "Leak", MeasureType.WATER_LEAK_DETECTION,
http(HttpGroup.CH_LEAK, "status")),
// `LIGHTNING` is the name in the spec, so we keep it here as it
LIGHTNING("lightning-distance", 0x60, "Lightning distance 1~40KM", MeasureType.LIGHTNING_DISTANCE,
http(HttpGroup.LIGHTNING, "distance")),
LIGHTNING_TIME("lightning-time", 0x61, "Lightning happened time", MeasureType.LIGHTNING_TIME,
http(HttpGroup.LIGHTNING, "date")),
// `LIGHTNING_POWER` is the name in the spec, so we keep it here as it
LIGHTNING_POWER("lightning-counter", 0x62, "lightning counter for the day", MeasureType.LIGHTNING_COUNTER,
http(HttpGroup.LIGHTNING, "count")),
TF_USRX(new int[] { 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A },
new MeasurandParser("temperature-external-channel", "Soil or Water temperature", MeasureType.TEMPERATURE)
.http(HttpGroup.CH_TEMP, "temp"),
// skip battery-level, since it is read via Command.CMD_READ_SENSOR_ID_NEW
new Skip(1)),
// 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),
WIND_DIRECTION_AVG_10M("direction-wind-avg-10min", 0x6D, "Wind Direction (10-minute average)", MeasureType.DEGREE,
DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_DIRECTION, http(HttpGroup.COMMON_LIST)),
ITEM_SENSOR_CO2_WH46(0x6B,
new MeasurandParser("sensor-co2-temperature", "Temperature (CO₂-Sensor)", MeasureType.TEMPERATURE)
.http(HttpGroup.CO2, "temp"),
new MeasurandParser("sensor-co2-humidity", "Humidity (CO₂-Sensor)", MeasureType.PERCENTAGE)
.http(HttpGroup.CO2, "humidity"),
new MeasurandParser("sensor-co2-pm10", "PM10 Air Quality (CO₂-Sensor)", MeasureType.PM10)
.http(HttpGroup.CO2, "PM10"),
new MeasurandParser("sensor-co2-pm10-24-hour-average", "PM10 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM10).http(HttpGroup.CO2, "PM10_24H"),
new MeasurandParser("sensor-co2-pm25", "PM2.5 Air Quality (CO₂-Sensor)", MeasureType.PM25)
.http(HttpGroup.CO2, "PM25"),
new MeasurandParser("sensor-co2-pm25-24-hour-average", "PM2.5 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM25).http(HttpGroup.CO2, "PM25_24H"),
new MeasurandParser("sensor-co2-co2", "CO₂", MeasureType.CO2).http(HttpGroup.CO2, "CO2"),
new MeasurandParser("sensor-co2-co2-24-hour-average", "CO₂ 24 hour average", MeasureType.CO2)
.http(HttpGroup.CO2, "CO2_24H"),
// 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).http(HttpGroup.CO2,
"PM1"),
new MeasurandParser("sensor-co2-pm1-24-hour-average", "PM1 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM1).http(HttpGroup.CO2, "PM1_24H"),
new MeasurandParser("sensor-co2-pm4", "PM4 Air Quality (CO₂-Sensor)", MeasureType.PM4).http(HttpGroup.CO2,
"PM4"),
new MeasurandParser("sensor-co2-pm4-24-hour-average", "PM4 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM4).http(HttpGroup.CO2, "PM4_24H")),
ITEM_SENSOR_CO2(0x70,
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),
// skip battery-level, since it is read via Command.CMD_READ_SENSOR_ID_NEW
new Skip(1)),
LEAF_WETNESS_CHX("leaf-wetness-channel", new int[] { 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79 },
"Leaf Moisture", MeasureType.PERCENTAGE, CHANNEL_TYPE_MOISTURE, http(HttpGroup.CH_LEAF, "humidity")),
/**
* 1 Traditional rain gauge
* 2 Piezoelectric rain gauge
*/
RAIN_PRIO(0x7a, new Skip(1)),
/**
* 0 = RFM433M
* 1 = RFM868M
* default = RFM915M
*/
RCSATION(0x7b, new Skip(1)),
// the piezo (haptic) rain gauge reuses the low rain item codes within the HTTP piezoRain group
PIEZO_RAIN_RATE("piezo-rain-rate", 0x80, "Rain Rate", MeasureType.HEIGHT_PER_HOUR,
http(HttpGroup.PIEZO_RAIN, 0x0E)),
PIEZO_EVENT_RAIN("piezo-rain-event", 0x81, "Rain Event", MeasureType.HEIGHT, http(HttpGroup.PIEZO_RAIN, 0x0D)),
PIEZO_HOURLY_RAIN("piezo-rain-hour", 0x82, "Rain hour", MeasureType.HEIGHT, http(HttpGroup.PIEZO_RAIN, 0x0F)),
PIEZO_DAILY_RAIN("piezo-rain-day", 0x83, "Rain Day", MeasureType.HEIGHT_BIG, http(HttpGroup.PIEZO_RAIN, 0x10)),
PIEZO_WEEKLY_RAIN("piezo-rain-week", 0x84, "Rain Week", MeasureType.HEIGHT_BIG, http(HttpGroup.PIEZO_RAIN, 0x11)),
PIEZO_MONTHLY_RAIN("piezo-rain-month", 0x85, "Rain Month", MeasureType.HEIGHT_BIG,
http(HttpGroup.PIEZO_RAIN, 0x12)),
PIEZO_YEARLY_RAIN("piezo-rain-year", 0x86, "Rain Year", MeasureType.HEIGHT_BIG, http(HttpGroup.PIEZO_RAIN, 0x13)),
PIEZO_GAIN10(0x87, new Skip(2 * 10)),
RST_RAIN_TIME(0x88, new Skip(3)),
// the WN38 black globe sensor reports a black globe temperature (BGT) and a wet bulb globe temperature (WBGT)
BLACK_GLOBE_TEMPERATURE("temperature-black-globe", 0xA1, "Black Globe Temperature", MeasureType.TEMPERATURE,
http(HttpGroup.COMMON_LIST)),
WET_BULB_GLOBE_TEMPERATURE("temperature-wet-bulb-globe", 0xA2, "Wet Bulb Globe Temperature",
MeasureType.TEMPERATURE, http(HttpGroup.COMMON_LIST)),
// measurands only available via the Ecowitt HTTP API (no TCP item code)
FEELS_LIKE("temperature-feels-like", "Feels like temperature", MeasureType.TEMPERATURE,
http(HttpGroup.COMMON_LIST, "3")),
VPD("vapor-pressure-deficit", "Vapor Pressure Deficit", MeasureType.VAPOR_PRESSURE_DEFICIT,
http(HttpGroup.COMMON_LIST, "5")),
// shares item code 0x15 with LIGHT; used when the gateway reports solar radiation in W/m² instead of lux
SOLAR_RADIATION("irradiation-solar", "Solar irradiation", MeasureType.SOLAR_RADIATION,
httpAlt(HttpGroup.COMMON_LIST, 0x15)),
// rainfall over the last 24 hours; only reported via the HTTP API (no dedicated TCP item code), in the rain group
// for the traditional gauge and in the piezoRain group for the haptic gauge - both keyed by id 0x7C
RAIN_24HOURS("rain-24-hours", "Rainfall last 24 hours", MeasureType.HEIGHT_BIG, http(HttpGroup.RAIN, "0x7C")),
PIEZO_RAIN_24HOURS("piezo-rain-24-hours", "Rainfall last 24 hours", MeasureType.HEIGHT_BIG,
http(HttpGroup.PIEZO_RAIN, "0x7C")),
PIEZO_RAIN_STATE("piezo-rain-state", "Raining (piezo)", MeasureType.RAIN_STATE,
http(HttpGroup.PIEZO_RAIN, "srain_piezo")),
;
private static final Map<Byte, SingleChannelMeasurand> MEASURANDS = new HashMap<>();
static {
for (Measurand measurand : values()) {
for (int i = 0; i < measurand.codes.length; i++) {
int code = measurand.codes[i];
// if we get more than one code this measurand has multiple channels
Integer channel = measurand.codes.length == 1 ? null : i + 1;
MEASURANDS.put((byte) code, new SingleChannelMeasurand(measurand, channel));
}
}
}
/**
* The lookup from an HTTP {@code get_livedata_info} group + id/field to a measurand's parser. It is built from the
* {@code http(...)} declarations on the enum constants above, so each data point's HTTP mapping lives in one place
* right next to its definition.
*/
private static final Map<HttpGroup, Map<String, HttpBinding>> HTTP_LOOKUP = new EnumMap<>(HttpGroup.class);
static {
for (Measurand measurand : values()) {
for (Parser parser : measurand.parsers) {
if (!(parser instanceof MeasurandParser measurandParser)) {
continue;
}
HttpSource source = measurandParser.httpSource;
if (source == null) {
continue;
}
Map<String, HttpBinding> bindings = Objects
.requireNonNull(HTTP_LOOKUP.computeIfAbsent(source.group, g -> new HashMap<>()));
String normalizedKey = normalizeId(source.resolveKey(measurand));
if (source.alternate) {
HttpBinding existing = bindings.get(normalizedKey);
if (existing != null) {
existing.setAlternate(measurandParser);
continue;
}
}
bindings.put(normalizedKey, new HttpBinding(measurandParser, null));
}
}
}
private static String codeKey(int code) {
return "0x" + Integer.toHexString(code & 0xFF);
}
static HttpSource http(HttpGroup group) {
return new HttpSource(group, null, null, false);
}
static HttpSource http(HttpGroup group, int httpCode) {
return new HttpSource(group, httpCode, null, false);
}
static HttpSource http(HttpGroup group, String key) {
return new HttpSource(group, null, key, false);
}
static HttpSource httpAlt(HttpGroup group, int httpCode) {
return new HttpSource(group, httpCode, null, true);
}
/**
* Normalizes a JSON {@code id} so that hex codes ({@code "0x02"}, {@code "0x0D"}) match regardless of casing or
* zero-padding, while decimal and string tokens ({@code "3"}, {@code "srain_piezo"}) are kept as their own keys -
* note that hex {@code "0x03"} and decimal {@code "3"} are deliberately different keys (dew point vs. feels-like).
*/
public static String normalizeId(String id) {
String trimmed = id.trim();
if (trimmed.length() > 2 && trimmed.charAt(0) == '0'
&& (trimmed.charAt(1) == 'x' || trimmed.charAt(1) == 'X')) {
try {
return "0x" + Integer.toHexString(Integer.parseInt(trimmed.substring(2), 16) & 0xFF);
} catch (NumberFormatException e) {
return trimmed.toLowerCase();
}
}
return trimmed;
}
public static @Nullable HttpBinding getHttpBinding(HttpGroup group, String id) {
Map<String, HttpBinding> map = HTTP_LOOKUP.get(group);
if (map == null) {
return null;
}
return map.get(normalizeId(id));
}
private final int[] codes;
private final Parser[] parsers;
/**
* Constructor for measurands that are only available via the HTTP API and therefore have no TCP item code.
*/
Measurand(String channelId, String name, MeasureType measureType, HttpSource httpSource) {
this(new int[0], new MeasurandParser(channelId, name, measureType).http(httpSource));
}
Measurand(String channelId, int code, String name, MeasureType measureType, ParserCustomization... customizations) {
this(channelId, code, name, measureType, (ChannelTypeUID) null, customizations);
}
Measurand(String channelId, int code, String name, MeasureType measureType, HttpSource httpSource,
ParserCustomization... customizations) {
this(new int[] { code },
new MeasurandParser(channelId, name, measureType, null, customizations).http(httpSource));
}
Measurand(String channelId, int[] codes, String name, MeasureType measureType,
ParserCustomization... customizations) {
this(channelId, codes, name, measureType, (ChannelTypeUID) null, customizations);
}
Measurand(String channelId, int[] codes, String name, MeasureType measureType, HttpSource httpSource,
ParserCustomization... customizations) {
this(codes, new MeasurandParser(channelId, name, measureType, null, customizations).http(httpSource));
}
Measurand(String channelId, int code, String name, MeasureType measureType, @Nullable ChannelTypeUID channelTypeUID,
ParserCustomization... customizations) {
this(code, new MeasurandParser(channelId, name, measureType, channelTypeUID, customizations));
}
Measurand(String channelId, int code, String name, MeasureType measureType, @Nullable ChannelTypeUID channelTypeUID,
HttpSource httpSource, ParserCustomization... customizations) {
this(new int[] { code },
new MeasurandParser(channelId, name, measureType, channelTypeUID, customizations).http(httpSource));
}
Measurand(String channelId, int[] codes, String name, MeasureType measureType,
@Nullable ChannelTypeUID channelTypeUID, ParserCustomization... customizations) {
this(codes, new MeasurandParser(channelId, name, measureType, channelTypeUID, customizations));
}
Measurand(String channelId, int[] codes, String name, MeasureType measureType,
@Nullable ChannelTypeUID channelTypeUID, HttpSource httpSource, ParserCustomization... customizations) {
this(codes, new MeasurandParser(channelId, name, measureType, channelTypeUID, customizations).http(httpSource));
}
Measurand(int code, Parser... parsers) {
this(new int[] { code }, parsers);
}
Measurand(int[] codes, Parser... parsers) {
this.codes = codes;
this.parsers = parsers;
}
public static @Nullable SingleChannelMeasurand getByCode(byte code) {
return MEASURANDS.get(code);
}
private int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType, List<MeasuredValue> result,
DebugDetails debugDetails) {
int subOffset = 0;
for (Parser parser : parsers) {
subOffset += parser.extractMeasuredValues(data, offset + subOffset, channel, customizationType, result,
debugDetails);
}
return subOffset;
}
private interface Parser {
int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType, List<MeasuredValue> result,
DebugDetails debugDetails);
}
private static class Skip implements Parser {
private final int skip;
public Skip(int skip) {
this.skip = skip;
}
@Override
public int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType, List<MeasuredValue> result,
DebugDetails debugDetails) {
debugDetails.addDebugDetails(offset, skip, "skipped");
return skip;
}
}
public enum ParserCustomizationType {
ELV,
RAIN_READING
}
private static class ParserCustomization {
private final ParserCustomizationType type;
private final MeasureType measureType;
public ParserCustomization(ParserCustomizationType type, MeasureType measureType) {
this.type = type;
this.measureType = measureType;
}
public ParserCustomizationType getType() {
return type;
}
public MeasureType getMeasureType() {
return measureType;
}
}
public static class SingleChannelMeasurand {
private final Measurand measurand;
private final @Nullable Integer channel;
public SingleChannelMeasurand(Measurand measurand, @Nullable Integer channel) {
this.measurand = measurand;
this.channel = channel;
}
public int extractMeasuredValues(byte[] data, int offset, @Nullable ParserCustomizationType customizationType,
List<MeasuredValue> result, DebugDetails debugDetails) {
return measurand.extractMeasuredValues(data, offset, channel, customizationType, result, debugDetails);
}
public String getDebugString() {
return measurand.name() + (channel == null ? "" : " channel " + channel);
}
}
private static class MeasurandParser implements Parser {
public class Measurand implements Parser {
private final String name;
private final String channelPrefix;
private final MeasureType measureType;
private final @Nullable Map<ParserCustomizationType, ParserCustomization> customizations;
private final @Nullable ChannelTypeUID channelTypeUID;
private @Nullable Map<ParserCustomizationType, ParserCustomization> customizations;
private @Nullable ChannelTypeUID channelTypeUID;
private @Nullable HttpSource httpSource;
MeasurandParser(String channelPrefix, String name, MeasureType measureType,
ParserCustomization... customizations) {
this(channelPrefix, name, measureType, null, customizations);
}
/**
* Declares where this value is found in the Ecowitt HTTP {@code get_livedata_info} response. Returns
* {@code this} so it can be chained when constructing the parser inline.
*/
MeasurandParser http(HttpSource httpSource) {
this.httpSource = httpSource;
return this;
}
MeasurandParser http(HttpGroup group, String key) {
return http(Measurand.http(group, key));
}
MeasurandParser(String channelPrefix, String name, MeasureType measureType,
@Nullable ChannelTypeUID channelTypeUID, ParserCustomization... customizations) {
Measurand(String channelPrefix, String name, MeasureType measureType) {
this.channelPrefix = channelPrefix;
this.name = name;
this.measureType = measureType;
this.channelTypeUID = channelTypeUID;
if (customizations.length == 0) {
this.customizations = null;
} else {
this.customizations = Collections.unmodifiableMap(
Arrays.stream(customizations).collect(Collectors.toMap(ParserCustomization::getType,
customization -> customization, (a, b) -> b, HashMap::new)));
}
Measurand http(HttpGroup group) {
this.httpSource = new HttpSource(group, null, null, false);
return this;
}
Measurand http(HttpGroup group, String key) {
this.httpSource = new HttpSource(group, null, key, false);
return this;
}
Measurand http(HttpGroup group, int httpCode) {
this.httpSource = new HttpSource(group, httpCode, null, false);
return this;
}
Measurand httpAlt(HttpGroup group, int httpCode) {
this.httpSource = new HttpSource(group, httpCode, null, true);
return this;
}
Measurand channelType(ChannelTypeUID channelTypeUID) {
this.channelTypeUID = channelTypeUID;
return this;
}
Measurand customization(ParserCustomizationType type, MeasureType measureType) {
Map<ParserCustomizationType, ParserCustomization> map = customizations;
if (map == null) {
map = new HashMap<>();
customizations = map;
}
map.put(type, new ParserCustomization(type, measureType));
return this;
}
@Nullable
HttpSource getHttpSource() {
return httpSource;
}
String getChannelPrefix() {
return channelPrefix;
}
@Override
@@ -580,72 +126,12 @@ public enum Measurand {
ChannelTypeUID channelType = channelTypeUID == null ? measureType.getChannelTypeId() : channelTypeUID;
return new MeasuredValue(measureType, channelPrefix, channel, channelType, state, name);
}
static Measurand measurand(String channelPrefix, String name, MeasureType measureType) {
return new Measurand(channelPrefix, name, measureType);
}
/**
* Binds an HTTP {@code get_livedata_info} entry to a measurand's parser. The optional {@code alternate} is used
* when the reported unit does not fit the primary parser's canonical dimension - this lets the lux illumination
* and the W/m² solar-radiation channel share the item code {@code 0x15}.
*/
public static final class HttpBinding {
private final MeasurandParser parser;
private @Nullable MeasurandParser alternate;
HttpBinding(MeasurandParser parser, @Nullable MeasurandParser alternate) {
this.parser = parser;
this.alternate = alternate;
}
void setAlternate(MeasurandParser alternate) {
this.alternate = alternate;
}
public @Nullable MeasuredValue parse(String val, @Nullable String unit, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType) {
MeasuredValue value = parser.parseHttp(val, unit, channel, customizationType);
@Nullable
MeasurandParser alternate = this.alternate;
if (value == null && alternate != null) {
value = alternate.parseHttp(val, unit, channel, customizationType);
}
return value;
}
}
/**
* Declares where a measurand's value is found in the Ecowitt HTTP {@code get_livedata_info} response, created via
* the {@link Measurand#http} / {@link Measurand#httpAlt} factory methods on the enum constants.
*/
static final class HttpSource {
private final HttpGroup group;
// explicit HTTP item code (used when it differs from the TCP code, e.g. the piezo rain channels)
private final @Nullable Integer httpCode;
// string id (e.g. "srain_piezo") or named field (e.g. "intemp"); null means "reuse the TCP item code"
private final @Nullable String key;
// whether this measurand is the dimension alternate for an already-registered code (see SOLAR_RADIATION)
private final boolean alternate;
private HttpSource(HttpGroup group, @Nullable Integer httpCode, @Nullable String key, boolean alternate) {
this.group = group;
this.httpCode = httpCode;
this.key = key;
this.alternate = alternate;
}
/**
* @return the lookup key: the explicit string id/field, the explicit HTTP item code, or - as a fallback for a
* measurand declaring {@code http(group)} - its single TCP item code
*/
private String resolveKey(Measurand measurand) {
String explicitKey = key;
if (explicitKey != null) {
return explicitKey;
}
Integer explicitCode = httpCode;
if (explicitCode != null) {
return codeKey(explicitCode);
}
return codeKey(measurand.codes[0]);
}
static Skip skip(int bytes) {
return new Skip(bytes);
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
/**
* A single TCP item code whose payload is an ordered sequence of {@link Parser} slots
* ({@link Measurand}s and {@link Skip}s) parsed sequentially.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public class MeasurandGroup implements Parser {
private final Parser[] slots;
MeasurandGroup(Parser... slots) {
this.slots = slots;
}
@Override
public int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType, List<MeasuredValue> result,
DebugDetails debugDetails) {
int subOffset = 0;
for (Parser slot : slots) {
subOffset += slot.extractMeasuredValues(data, offset + subOffset, channel, customizationType, result,
debugDetails);
}
return subOffset;
}
}
@@ -0,0 +1,404 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_FREE_HEAP_SIZE;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_MAX_WIND_SPEED;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_MOISTURE;
import static org.openhab.binding.fineoffsetweatherstation.internal.FineOffsetWeatherStationBindingConstants.CHANNEL_TYPE_UV_INDEX;
import static org.openhab.binding.fineoffsetweatherstation.internal.domain.Measurand.measurand;
import static org.openhab.binding.fineoffsetweatherstation.internal.domain.Measurand.skip;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
/**
* Indexes all measurand definitions for lookup by TCP item code and by HTTP group + id.
* Built once from the fluent {@link #builder()} DSL; reached via {@link #standard()}.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public class MeasurandRegistry {
private static @Nullable MeasurandRegistry standard;
/**
* TCP index: a binary live-data item {@code code} (one byte) to what it parses into - see {@link #tcpByCode}.
*/
private final Map<Byte, CodeBinding> byCode;
/**
* Ecowitt HTTP API index: an {@link HttpGroup} to a map of normalized id/field to its {@link HttpBinding} - see
* {@link #http}.
*/
private final Map<HttpGroup, Map<String, HttpBinding>> byHttp;
private MeasurandRegistry(Map<Byte, CodeBinding> byCode, Map<HttpGroup, Map<String, HttpBinding>> byHttp) {
this.byCode = byCode;
this.byHttp = byHttp;
}
public static synchronized MeasurandRegistry standard() {
MeasurandRegistry result = standard;
if (result == null) {
result = defineStandard(builder()).build();
standard = result;
}
return result;
}
static Builder builder() {
return new Builder();
}
/**
* Resolves a TCP live-data item code (as sent over the binary protocol and parsed by {@code FineOffsetDataParser})
* to its {@link CodeBinding}, or {@code null} if the code is unknown.
*/
public @Nullable CodeBinding tcpByCode(byte code) {
return byCode.get(code);
}
/**
* Resolves an entry of the Ecowitt {@code get_livedata_info} HTTP response (as parsed by {@code EcowittDataParser})
* - identified by its {@link HttpGroup} and id/field - to its {@link HttpBinding}, or {@code null} if unknown. The
* {@code id} is matched after passing through {@link #normalizeId}.
*/
public @Nullable HttpBinding http(HttpGroup group, String id) {
Map<String, HttpBinding> map = byHttp.get(group);
return map == null ? null : map.get(normalizeId(id));
}
/**
* Normalizes a JSON {@code id} so that hex codes ({@code "0x02"}, {@code "0x0D"}) match regardless of casing or
* zero-padding, while decimal and string tokens ({@code "3"}, {@code "srain_piezo"}) are kept as their own keys -
* note that hex {@code "0x03"} and decimal {@code "3"} are deliberately different keys (dew point vs. feels-like).
*/
public static String normalizeId(String id) {
String trimmed = id.trim();
if (trimmed.length() > 2 && trimmed.charAt(0) == '0'
&& (trimmed.charAt(1) == 'x' || trimmed.charAt(1) == 'X')) {
try {
return "0x" + Integer.toHexString(Integer.parseInt(trimmed.substring(2), 16) & 0xFF);
} catch (NumberFormatException e) {
return trimmed.toLowerCase();
}
}
return trimmed;
}
private static Builder defineStandard(Builder b) {
b.add(0x01,
measurand("temperature-indoor", "Indoor Temperature", MeasureType.TEMPERATURE)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_INDOOR_TEMPERATURE)
.http(HttpGroup.WH25, "intemp"));
b.add(0x02,
measurand("temperature-outdoor", "Outdoor Temperature", MeasureType.TEMPERATURE)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_OUTDOOR_TEMPERATURE)
.http(HttpGroup.COMMON_LIST));
b.add(0x03,
measurand("temperature-dew-point", "Dew point", MeasureType.TEMPERATURE).http(HttpGroup.COMMON_LIST));
b.add(0x04,
measurand("temperature-wind-chill", "Wind chill", MeasureType.TEMPERATURE).http(HttpGroup.COMMON_LIST));
b.add(0x05,
measurand("temperature-heat-index", "Heat index", MeasureType.TEMPERATURE).http(HttpGroup.COMMON_LIST));
b.add(0x06,
measurand("humidity-indoor", "Indoor Humidity", MeasureType.PERCENTAGE).http(HttpGroup.WH25, "inhumi"));
b.add(0x07,
measurand("humidity-outdoor", "Outdoor Humidity", MeasureType.PERCENTAGE)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_ATMOSPHERIC_HUMIDITY)
.http(HttpGroup.COMMON_LIST));
b.add(0x08, measurand("pressure-absolute", "Absolutely pressure", MeasureType.PRESSURE).http(HttpGroup.WH25,
"abs"));
b.add(0x09,
measurand("pressure-relative", "Relative pressure", MeasureType.PRESSURE)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_BAROMETRIC_PRESSURE)
.http(HttpGroup.WH25, "rel"));
b.add(0x0A,
measurand("direction-wind", "Wind Direction", MeasureType.DEGREE)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_DIRECTION)
.http(HttpGroup.COMMON_LIST));
b.add(0x0B,
measurand("speed-wind", "Wind Speed", MeasureType.SPEED)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_SPEED)
.http(HttpGroup.COMMON_LIST));
b.add(0x0C,
measurand("speed-gust", "Gust Speed", MeasureType.SPEED)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_SPEED)
.http(HttpGroup.COMMON_LIST));
b.add(0x0D, measurand("rain-event", "Rain Event", MeasureType.HEIGHT).http(HttpGroup.RAIN)
.customization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG));
b.add(0x0E, measurand("rain-rate", "Rain Rate", MeasureType.HEIGHT_PER_HOUR).http(HttpGroup.RAIN)
.customization(ParserCustomizationType.ELV, MeasureType.HEIGHT_PER_HOUR_BIG));
b.add(0x0F, measurand("rain-hour", "Rain hour", MeasureType.HEIGHT).http(HttpGroup.RAIN)
.customization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG));
b.add(0x10,
measurand("rain-day", "Rain Day", MeasureType.HEIGHT).http(HttpGroup.RAIN)
.customization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG)
.customization(ParserCustomizationType.RAIN_READING, MeasureType.HEIGHT_BIG));
b.add(0x11,
measurand("rain-week", "Rain Week", MeasureType.HEIGHT).http(HttpGroup.RAIN)
.customization(ParserCustomizationType.ELV, MeasureType.HEIGHT_BIG)
.customization(ParserCustomizationType.RAIN_READING, MeasureType.HEIGHT_BIG));
b.add(0x12, measurand("rain-month", "Rain Month", MeasureType.HEIGHT_BIG).http(HttpGroup.RAIN));
b.add(0x13, measurand("rain-year", "Rain Year", MeasureType.HEIGHT_BIG).http(HttpGroup.RAIN));
b.add(0x14, measurand("rain-total", "Rain Totals", MeasureType.HEIGHT_BIG).http(HttpGroup.RAIN));
b.add(0x15, measurand("illumination", "Light", MeasureType.LUX).http(HttpGroup.COMMON_LIST));
b.add(0x16,
measurand("irradiation-uv", "UV", MeasureType.MILLIWATT_PER_SQUARE_METRE).http(HttpGroup.COMMON_LIST));
b.add(0x17, measurand("uv-index", "UV index", MeasureType.BYTE).channelType(CHANNEL_TYPE_UV_INDEX)
.http(HttpGroup.COMMON_LIST));
b.add(0x18, measurand("time", "Date and time", MeasureType.DATE_TIME2));
b.add(0X19, measurand("wind-max-day", "Day max wind", MeasureType.SPEED)
.channelType(CHANNEL_TYPE_MAX_WIND_SPEED).http(HttpGroup.COMMON_LIST));
b.addChannels(new int[] { 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21 },
measurand("temperature-channel", "Temperature", MeasureType.TEMPERATURE).http(HttpGroup.CH_AISLE,
"temp"));
b.addChannels(new int[] { 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29 },
measurand("humidity-channel", "Humidity", MeasureType.PERCENTAGE).http(HttpGroup.CH_AISLE, "humidity"));
b.addChannels(new int[] { 0x2B, 0x2D, 0x2F, 0x31, 0x33, 0x35, 0x37, 0x39, 0x3B, 0x3D, 0x3F, 0x41, 0x43, 0x45,
0x47, 0x49 }, measurand("temperature-soil-channel", "Soil Temperature", MeasureType.TEMPERATURE));
b.addChannels(
new int[] { 0x2C, 0x2E, 0x30, 0x32, 0x34, 0x36, 0x38, 0x3A, 0x3C, 0x3E, 0x40, 0x42, 0x44, 0x46, 0x48,
0x4A },
measurand("moisture-soil-channel", "Soil Moisture", MeasureType.PERCENTAGE)
.channelType(CHANNEL_TYPE_MOISTURE).http(HttpGroup.CH_SOIL, "humidity"));
b.skip(0x4C, 1);
b.addChannels(new int[] { 0x4D, 0x4E, 0x4F, 0x50 }, measurand("air-quality-24-hour-average-channel",
"PM2.5 Air Quality 24 hour average", MeasureType.PM25));
b.addChannels(new int[] { 0x2A, 0x51, 0x52, 0x53 },
measurand("air-quality-channel", "PM2.5 Air Quality", MeasureType.PM25).http(HttpGroup.CH_PM25,
"PM25"));
b.addChannels(new int[] { 0x58, 0x59, 0x5A, 0x5B },
measurand("water-leak-channel", "Leak", MeasureType.WATER_LEAK_DETECTION).http(HttpGroup.CH_LEAK,
"status"));
b.add(0x60, measurand("lightning-distance", "Lightning distance 1~40KM", MeasureType.LIGHTNING_DISTANCE)
.http(HttpGroup.LIGHTNING, "distance"));
b.add(0x61, measurand("lightning-time", "Lightning happened time", MeasureType.LIGHTNING_TIME)
.http(HttpGroup.LIGHTNING, "date"));
b.add(0x62, measurand("lightning-counter", "lightning counter for the day", MeasureType.LIGHTNING_COUNTER)
.http(HttpGroup.LIGHTNING, "count"));
b.addChannels(new int[] { 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A },
measurand("temperature-external-channel", "Soil or Water temperature", MeasureType.TEMPERATURE)
.http(HttpGroup.CH_TEMP, "temp"),
// skip battery-level, since it is read via Command.CMD_READ_SENSOR_ID_NEW
skip(1));
b.addGroup(0x6B,
measurand("sensor-co2-temperature", "Temperature (CO₂-Sensor)", MeasureType.TEMPERATURE)
.http(HttpGroup.CO2, "temp"),
measurand("sensor-co2-humidity", "Humidity (CO₂-Sensor)", MeasureType.PERCENTAGE).http(HttpGroup.CO2,
"humidity"),
measurand("sensor-co2-pm10", "PM10 Air Quality (CO₂-Sensor)", MeasureType.PM10).http(HttpGroup.CO2,
"PM10"),
measurand("sensor-co2-pm10-24-hour-average", "PM10 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM10).http(HttpGroup.CO2, "PM10_24H"),
measurand("sensor-co2-pm25", "PM2.5 Air Quality (CO₂-Sensor)", MeasureType.PM25).http(HttpGroup.CO2,
"PM25"),
measurand("sensor-co2-pm25-24-hour-average", "PM2.5 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM25).http(HttpGroup.CO2, "PM25_24H"),
measurand("sensor-co2-co2", "CO₂", MeasureType.CO2).http(HttpGroup.CO2, "CO2"),
measurand("sensor-co2-co2-24-hour-average", "CO₂ 24 hour average", MeasureType.CO2).http(HttpGroup.CO2,
"CO2_24H"),
// 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
measurand("sensor-co2-wh46-battery-level", "Battery Level WH46", MeasureType.BATTERY_LEVEL),
measurand("sensor-co2-pm1", "PM1 Air Quality (CO₂-Sensor)", MeasureType.PM1).http(HttpGroup.CO2, "PM1"),
measurand("sensor-co2-pm1-24-hour-average", "PM1 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM1).http(HttpGroup.CO2, "PM1_24H"),
measurand("sensor-co2-pm4", "PM4 Air Quality (CO₂-Sensor)", MeasureType.PM4).http(HttpGroup.CO2, "PM4"),
measurand("sensor-co2-pm4-24-hour-average", "PM4 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM4).http(HttpGroup.CO2, "PM4_24H"));
b.add(0x6c, measurand("free-heap-size", "Free Heap Size", MeasureType.MEMORY)
.channelType(CHANNEL_TYPE_FREE_HEAP_SIZE));
b.add(0x6D,
measurand("direction-wind-avg-10min", "Wind Direction (10-minute average)", MeasureType.DEGREE)
.channelType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_WIND_DIRECTION)
.http(HttpGroup.COMMON_LIST));
b.addGroup(0x70, measurand("sensor-co2-temperature", "Temperature (CO₂-Sensor)", MeasureType.TEMPERATURE),
measurand("sensor-co2-humidity", "Humidity (CO₂-Sensor)", MeasureType.PERCENTAGE),
measurand("sensor-co2-pm10", "PM10 Air Quality (CO₂-Sensor)", MeasureType.PM10),
measurand("sensor-co2-pm10-24-hour-average", "PM10 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM10),
measurand("sensor-co2-pm25", "PM2.5 Air Quality (CO₂-Sensor)", MeasureType.PM25),
measurand("sensor-co2-pm25-24-hour-average", "PM2.5 Air Quality 24 hour average (CO₂-Sensor)",
MeasureType.PM25),
measurand("sensor-co2-co2", "CO₂", MeasureType.CO2),
measurand("sensor-co2-co2-24-hour-average", "CO₂ 24 hour average", MeasureType.CO2),
// skip battery-level, since it is read via Command.CMD_READ_SENSOR_ID_NEW
skip(1));
b.addChannels(new int[] { 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79 },
measurand("leaf-wetness-channel", "Leaf Moisture", MeasureType.PERCENTAGE)
.channelType(CHANNEL_TYPE_MOISTURE).http(HttpGroup.CH_LEAF, "humidity"));
b.skip(0x7a, 1);
b.skip(0x7b, 1);
b.add(0x80, measurand("piezo-rain-rate", "Rain Rate", MeasureType.HEIGHT_PER_HOUR).http(HttpGroup.PIEZO_RAIN,
0x0E));
b.add(0x81, measurand("piezo-rain-event", "Rain Event", MeasureType.HEIGHT).http(HttpGroup.PIEZO_RAIN, 0x0D));
b.add(0x82, measurand("piezo-rain-hour", "Rain hour", MeasureType.HEIGHT).http(HttpGroup.PIEZO_RAIN, 0x0F));
b.add(0x83, measurand("piezo-rain-day", "Rain Day", MeasureType.HEIGHT_BIG).http(HttpGroup.PIEZO_RAIN, 0x10));
b.add(0x84, measurand("piezo-rain-week", "Rain Week", MeasureType.HEIGHT_BIG).http(HttpGroup.PIEZO_RAIN, 0x11));
b.add(0x85,
measurand("piezo-rain-month", "Rain Month", MeasureType.HEIGHT_BIG).http(HttpGroup.PIEZO_RAIN, 0x12));
b.add(0x86, measurand("piezo-rain-year", "Rain Year", MeasureType.HEIGHT_BIG).http(HttpGroup.PIEZO_RAIN, 0x13));
b.skip(0x87, 2 * 10);
b.skip(0x88, 3);
b.add(0xA1, measurand("temperature-black-globe", "Black Globe Temperature", MeasureType.TEMPERATURE)
.http(HttpGroup.COMMON_LIST));
b.add(0xA2, measurand("temperature-wet-bulb-globe", "Wet Bulb Globe Temperature", MeasureType.TEMPERATURE)
.http(HttpGroup.COMMON_LIST));
b.addHttpOnly(measurand("temperature-feels-like", "Feels like temperature", MeasureType.TEMPERATURE)
.http(HttpGroup.COMMON_LIST, "3"));
b.addHttpOnly(measurand("vapor-pressure-deficit", "Vapor Pressure Deficit", MeasureType.VAPOR_PRESSURE_DEFICIT)
.http(HttpGroup.COMMON_LIST, "5"));
// shares item code 0x15 with LIGHT; used when the gateway reports solar radiation in W/m² instead of lux
b.addHttpOnly(measurand("irradiation-solar", "Solar irradiation", MeasureType.SOLAR_RADIATION)
.httpAlt(HttpGroup.COMMON_LIST, 0x15));
// rainfall over the last 24 hours; only reported via the HTTP API (no dedicated TCP item code)
b.addHttpOnly(measurand("rain-24-hours", "Rainfall last 24 hours", MeasureType.HEIGHT_BIG).http(HttpGroup.RAIN,
"0x7C"));
b.addHttpOnly(measurand("piezo-rain-24-hours", "Rainfall last 24 hours", MeasureType.HEIGHT_BIG)
.http(HttpGroup.PIEZO_RAIN, "0x7C"));
b.addHttpOnly(measurand("piezo-rain-state", "Raining (piezo)", MeasureType.RAIN_STATE)
.http(HttpGroup.PIEZO_RAIN, "srain_piezo"));
return b;
}
/**
* Collects measurand definitions one at a time and incrementally fills the TCP ({@code byCode}) and HTTP
* ({@code byHttp}) indexes. Each {@code add*}/{@code skip} call registers a single definition (the relative call
* order matters for HTTP alternates - see {@link #registerHttp}); {@link #build()} freezes the result.
*/
@SuppressWarnings("UnusedReturnValue")
static final class Builder {
private final Map<Byte, CodeBinding> byCode = new HashMap<>();
private final Map<HttpGroup, Map<String, HttpBinding>> byHttp = new HashMap<>();
/**
* Registers a single-channel measurand: the TCP item {@code code} resolves to {@code measurand} (no channel
* index),
* and - if {@code measurand} declares an HTTP source - it is indexed for the Ecowitt API under that code's key.
*/
Builder add(int code, Measurand measurand) {
byCode.put((byte) code, new CodeBinding(measurand, null, measurand.getChannelPrefix()));
registerHttp(measurand, code);
return this;
}
/**
* Registers one measurand spread across several TCP item codes, one per sensor channel: {@code codes[i]}
* resolves to {@code measurand} with channel {@code i+1}. The HTTP binding (if any) is registered once, keyed
* via the
* first code.
*/
Builder addChannels(int[] codes, Measurand measurand) {
for (int i = 0; i < codes.length; i++) {
byCode.put((byte) codes[i], new CodeBinding(measurand, i + 1, measurand.getChannelPrefix()));
}
registerHttp(measurand, codes[0]);
return this;
}
/**
* Multi-channel measurand whose per-code payload is the {@code measurand} parser followed by fixed
* {@code trailingSlots}
* (e.g. a trailing {@link Skip}). For each code {@code i} the channel {@code i+1} is bound; the slots after
* {@code measurand} ignore the channel. HTTP for {@code measurand} is registered once under the first code.
*/
Builder addChannels(int[] codes, Measurand measurand, Parser... trailingSlots) {
Parser[] slots = new Parser[trailingSlots.length + 1];
slots[0] = measurand;
System.arraycopy(trailingSlots, 0, slots, 1, trailingSlots.length);
for (int i = 0; i < codes.length; i++) {
byCode.put((byte) codes[i],
new CodeBinding(new MeasurandGroup(slots), i + 1, measurand.getChannelPrefix()));
}
registerHttp(measurand, codes[0]);
return this;
}
/**
* Registers a measurand that exists only in the Ecowitt HTTP API and has no TCP item code. {@code measurand}
* must
* therefore declare an explicit HTTP key or http-code (the TCP-code fallback is never available here).
*/
Builder addHttpOnly(Measurand measurand) {
registerHttp(measurand, null);
return this;
}
/**
* Registers a compound TCP item {@code code} whose payload is the ordered {@code slots} ({@link Measurand}s and
* {@link Skip}s) parsed sequentially as one block. Each {@link Measurand} slot that declares an HTTP source is
* indexed for the Ecowitt API individually.
*/
Builder addGroup(int code, Parser... slots) {
byCode.put((byte) code,
new CodeBinding(new MeasurandGroup(slots), null, "group 0x" + Integer.toHexString(code & 0xFF)));
for (Parser slot : slots) {
if (slot instanceof Measurand m) {
registerHttp(m, code);
}
}
return this;
}
/**
* Registers a TCP item {@code code} whose {@code bytes}-long payload is consumed and discarded - it produces no
* channel and has no Ecowitt HTTP API representation (e.g. battery levels read elsewhere, gauge-type markers).
*/
Builder skip(int code, int bytes) {
byCode.put((byte) code, new CodeBinding(new Skip(bytes), null, "skipped"));
return this;
}
/**
* Indexes {@code measurand}'s HTTP source (if it has one) under its {@link HttpGroup} and {@link #normalizeId
* normalized} key. An {@link HttpSource#isAlternate() alternate} source attaches to the primary already
* registered for that key instead of replacing it (e.g. solar radiation in W/measurand² sharing item code
* {@code 0x15}
* with lux illumination), so the primary must be registered first. {@code tcpCode} supplies the key only when
* the source declares neither an explicit key nor an explicit http-code.
*/
void registerHttp(Measurand measurand, @Nullable Integer tcpCode) {
HttpSource source = measurand.getHttpSource();
if (source == null) {
return;
}
Map<String, HttpBinding> bindings = Objects
.requireNonNull(byHttp.computeIfAbsent(source.getGroup(), g -> new HashMap<>()));
String key = normalizeId(source.resolveKey(tcpCode));
if (source.isAlternate()) {
HttpBinding existing = bindings.get(key);
if (existing != null) {
existing.setAlternate(measurand);
return;
}
}
bindings.put(key, new HttpBinding(measurand, null));
}
/**
* Freezes the collected TCP and HTTP indexes into an immutable {@link MeasurandRegistry}.
*/
public MeasurandRegistry build() {
Map<HttpGroup, Map<String, HttpBinding>> frozenHttp = new HashMap<>();
byHttp.forEach((group, bindings) -> frozenHttp.put(group, Map.copyOf(bindings)));
return new MeasurandRegistry(Map.copyOf(byCode), Map.copyOf(frozenHttp));
}
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
/**
* Parser for a single measurand's binary payload.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
interface Parser {
/**
* Parses this parser's slice of a TCP item payload, appending any decoded values to {@code result}.
*
* @param data the full response buffer
* @param offset the index in {@code data} where this parser's payload starts
* @param channel the 1-based sensor channel for multi-channel measurands, or {@code null} for single-channel ones
* @param customizationType the active protocol-specific reading variant, or {@code null} for the default
* @param result collects the decoded {@link MeasuredValue}s
* @param debugDetails accumulates a human-readable description of the parsed bytes
* @return the number of bytes consumed starting at {@code offset}
*/
int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType, List<MeasuredValue> result, DebugDetails debugDetails);
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Overrides a measurand's {@link MeasureType} for a specific {@link ParserCustomizationType}.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public class ParserCustomization {
private final ParserCustomizationType type;
private final MeasureType measureType;
public ParserCustomization(ParserCustomizationType type, MeasureType measureType) {
this.type = type;
this.measureType = measureType;
}
public ParserCustomizationType getType() {
return type;
}
public MeasureType getMeasureType() {
return measureType;
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The protocol-specific reading variants that override a measurand's {@link MeasureType}.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
public enum ParserCustomizationType {
ELV,
RAIN_READING
}
@@ -29,19 +29,19 @@ import org.openhab.binding.fineoffsetweatherstation.internal.service.GatewayQuer
@NonNullByDefault
public enum Protocol {
DEFAULT(FineOffsetGatewayQueryService::new, null),
ELV(ELVGatewayQueryService::new, Measurand.ParserCustomizationType.ELV),
ELV(ELVGatewayQueryService::new, ParserCustomizationType.ELV),
HTTP_ECOWITT(EcowittHttpGatewayQueryService::new, null);
private final GatewayQueryServiceFactory queryServiceFactory;
private final Measurand.@Nullable ParserCustomizationType parserCustomizationType;
private final @Nullable ParserCustomizationType parserCustomizationType;
Protocol(GatewayQueryServiceFactory queryServiceFactory,
Measurand.@Nullable ParserCustomizationType parserCustomizationType) {
@Nullable ParserCustomizationType parserCustomizationType) {
this.queryServiceFactory = queryServiceFactory;
this.parserCustomizationType = parserCustomizationType;
}
public Measurand.@Nullable ParserCustomizationType getParserCustomizationType() {
public @Nullable ParserCustomizationType getParserCustomizationType() {
return parserCustomizationType;
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
/**
* A {@link Parser} that skips a fixed number of bytes without extracting any value.
*
* @author Andreas Berger - Initial contribution
*/
@NonNullByDefault
class Skip implements Parser {
private final int skip;
public Skip(int skip) {
this.skip = skip;
}
@Override
public int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel,
@Nullable ParserCustomizationType customizationType, List<MeasuredValue> result,
DebugDetails debugDetails) {
debugDetails.addDebugDetails(offset, skip, "skipped");
return skip;
}
}
@@ -21,9 +21,9 @@ import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.HttpBinding;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.HttpGroup;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.Measurand;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.Measurand.HttpBinding;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.MeasurandRegistry;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.SensorGatewayBinding;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.BatteryStatus;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
@@ -41,7 +41,8 @@ import com.google.gson.JsonSyntaxException;
/**
* Parses the JSON responses of the Ecowitt HTTP API into the same domain objects the TCP parser produces.
* <p>
* The measurand/unit knowledge lives in {@link Measurand} and
* The measurand/unit knowledge lives in
* {@link org.openhab.binding.fineoffsetweatherstation.internal.domain.Measurand} and
* {@link org.openhab.binding.fineoffsetweatherstation.internal.domain.MeasureType}; this class only walks the JSON
* structure and dispatches each value to the matching measurand.
*
@@ -54,6 +55,7 @@ public class EcowittDataParser {
private static final String REGISTERING_SENSOR = "FFFFFFFF";
private final Logger logger = LoggerFactory.getLogger(EcowittDataParser.class);
private final MeasurandRegistry registry = MeasurandRegistry.standard();
/**
* Parses a {@code get_livedata_info} response into the measured values.
@@ -100,7 +102,7 @@ public class EcowittDataParser {
@Nullable
String unit = entry.has("unit") ? entry.get("unit").getAsString() : null;
@Nullable
HttpBinding binding = Measurand.getHttpBinding(group, id);
HttpBinding binding = registry.http(group, id);
if (binding == null) {
logger.debug("no measurand for id '{}' in group '{}'", id, group.getJsonKey());
return;
@@ -113,7 +115,7 @@ public class EcowittDataParser {
String unit = obj.has("unit") ? obj.get("unit").getAsString() : null;
for (Map.Entry<String, JsonElement> field : obj.entrySet()) {
@Nullable
HttpBinding binding = Measurand.getHttpBinding(group, field.getKey());
HttpBinding binding = registry.http(group, field.getKey());
if (binding == null) {
// structural fields like "channel", "name", "unit", "battery" simply have no measurand
continue;
@@ -26,8 +26,10 @@ import java.util.function.Supplier;
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.CodeBinding;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.DebugDetails;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.Measurand;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.MeasurandRegistry;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.ParserCustomizationType;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.Protocol;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.SensorGatewayBinding;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.BatteryStatus;
@@ -46,6 +48,7 @@ import org.slf4j.LoggerFactory;
public class FineOffsetDataParser {
private final Logger logger = LoggerFactory.getLogger(FineOffsetDataParser.class);
private final Protocol protocol;
private final MeasurandRegistry registry = MeasurandRegistry.standard();
public FineOffsetDataParser(Protocol protocol) {
this.protocol = protocol;
@@ -170,17 +173,17 @@ public class FineOffsetDataParser {
}
List<MeasuredValue> getRainData(byte[] data, DebugDetails debugDetails) {
return readMeasuredValues(data, 5, Measurand.ParserCustomizationType.RAIN_READING, debugDetails);
return readMeasuredValues(data, 5, ParserCustomizationType.RAIN_READING, debugDetails);
}
private List<MeasuredValue> readMeasuredValues(byte[] data, int idx,
Measurand.@Nullable ParserCustomizationType protocol, DebugDetails debugDetails) {
private List<MeasuredValue> readMeasuredValues(byte[] data, int idx, @Nullable ParserCustomizationType protocol,
DebugDetails debugDetails) {
var size = toUInt16(data, 3);
List<MeasuredValue> result = new ArrayList<>();
while (idx < size) {
byte code = data[idx++];
Measurand.SingleChannelMeasurand measurand = Measurand.getByCode(code);
CodeBinding measurand = registry.tcpByCode(code);
if (measurand == null) {
logger.warn("failed to get measurand 0x{}", Integer.toHexString(code));
debugDetails.addDebugDetails(idx - 1, 1, "unknown measurand");
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.fineoffsetweatherstation.internal.domain;
import java.util.Objects;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.openhab.binding.fineoffsetweatherstation.internal.domain.response.MeasuredValue;
/**
* Tests for {@link MeasurandRegistry}.
*
* @author Andreas Berger - Initial contribution
*/
class MeasurandRegistryTest {
private final MeasurandRegistry registry = MeasurandRegistry.standard();
@Test
void tcpSingleCodeResolves() {
Assertions.assertThat(registry.tcpByCode((byte) 0x02)).isNotNull();
}
@Test
void tcpMultiChannelAssignsChannelByIndex() {
// 0x2C is soil moisture channel 1, 0x2E is channel 2
Assertions.assertThat(registry.tcpByCode((byte) 0x2C).getDebugString()).contains("channel 1");
Assertions.assertThat(registry.tcpByCode((byte) 0x2E).getDebugString()).contains("channel 2");
}
@Test
void httpKeyedResolves() {
Assertions.assertThat(registry.http(HttpGroup.CH_SOIL, "humidity")).isNotNull();
}
@Test
void httpAlternateSharesCodeWithPrimary() {
// 0x15 is illumination (lux) with solar-radiation (W/m²) as the dimension alternate
HttpBinding binding = Objects.requireNonNull(registry.http(HttpGroup.COMMON_LIST, "0x15"));
// a lux reading resolves to the primary illumination channel...
MeasuredValue lux = Objects.requireNonNull(binding.parse("12.3 Klux", null, null, null));
Assertions.assertThat(lux.getChannelId()).isEqualTo("illumination");
// ...while a W/m² reading the primary can't represent falls through to the solar-radiation alternate
MeasuredValue solar = Objects.requireNonNull(binding.parse("365.66 W/m2", null, null, null));
Assertions.assertThat(solar.getChannelId()).isEqualTo("irradiation-solar");
}
@Test
void normalizeIdKeepsHexAndDecimalDistinct() {
Assertions.assertThat(MeasurandRegistry.normalizeId("0x03")).isNotEqualTo(MeasurandRegistry.normalizeId("3"));
}
@Test
void unknownCodeReturnsNull() {
Assertions.assertThat(registry.tcpByCode((byte) 0xEE)).isNull();
}
}