From a13e2c6b69683b2176521f722b5bde59ca87bcac Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Fri, 19 Jun 2026 15:26:50 +0200 Subject: [PATCH] [fineoffsetweatherstation] Replace Measurand enum with a MeasurandRegistry built from a fluent DSL (#21003) * [fineoffsetweatherstation] Extract ParserCustomizationType to top level Signed-off-by: Andreas Berger --- .../internal/domain/CodeBinding.java | 47 ++ .../internal/domain/HttpBinding.java | 50 ++ .../internal/domain/HttpSource.java | 70 ++ .../internal/domain/Measurand.java | 648 ++---------------- .../internal/domain/MeasurandGroup.java | 46 ++ .../internal/domain/MeasurandRegistry.java | 404 +++++++++++ .../internal/domain/Parser.java | 41 ++ .../internal/domain/ParserCustomization.java | 39 ++ .../domain/ParserCustomizationType.java | 26 + .../internal/domain/Protocol.java | 8 +- .../internal/domain/Skip.java | 41 ++ .../internal/service/EcowittDataParser.java | 12 +- .../service/FineOffsetDataParser.java | 13 +- .../domain/MeasurandRegistryTest.java | 67 ++ 14 files changed, 917 insertions(+), 595 deletions(-) create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/CodeBinding.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpBinding.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpSource.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandGroup.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistry.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Parser.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomization.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomizationType.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Skip.java create mode 100644 bundles/org.openhab.binding.fineoffsetweatherstation/src/test/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistryTest.java diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/CodeBinding.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/CodeBinding.java new file mode 100644 index 0000000000..db364cedfd --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/CodeBinding.java @@ -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 result, DebugDetails debugDetails) { + return parser.extractMeasuredValues(data, offset, channel, customizationType, result, debugDetails); + } + + public String getDebugString() { + return debugName + (channel == null ? "" : " channel " + channel); + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpBinding.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpBinding.java new file mode 100644 index 0000000000..1b1dc3b121 --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpBinding.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpSource.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpSource.java new file mode 100644 index 0000000000..69780d8ccc --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/HttpSource.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Measurand.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Measurand.java index e7960b123d..6532bb0ed5 100644 --- a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Measurand.java +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Measurand.java @@ -12,640 +12,126 @@ */ 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 { +public class Measurand implements Parser { + private final String name; + private final String channelPrefix; + private final MeasureType measureType; - INTEMP("temperature-indoor", 0x01, "Indoor Temperature", MeasureType.TEMPERATURE, - DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_INDOOR_TEMPERATURE, - http(HttpGroup.WH25, "intemp")), + private @Nullable Map customizations; + private @Nullable ChannelTypeUID channelTypeUID; - OUTTEMP("temperature-outdoor", 0x02, "Outdoor Temperature", MeasureType.TEMPERATURE, - DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_OUTDOOR_TEMPERATURE, http(HttpGroup.COMMON_LIST)), + private @Nullable HttpSource httpSource; - 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 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)); - } - } + Measurand(String channelPrefix, String name, MeasureType measureType) { + this.channelPrefix = channelPrefix; + this.name = name; + this.measureType = measureType; } - /** - * 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> 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 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)); - } - } + Measurand http(HttpGroup group) { + this.httpSource = new HttpSource(group, null, null, false); + return this; } - private static String codeKey(int code) { - return "0x" + Integer.toHexString(code & 0xFF); + Measurand http(HttpGroup group, String key) { + this.httpSource = new HttpSource(group, null, key, false); + return this; } - static HttpSource http(HttpGroup group) { - return new HttpSource(group, null, null, false); + Measurand http(HttpGroup group, int httpCode) { + this.httpSource = new HttpSource(group, httpCode, null, false); + return this; } - static HttpSource http(HttpGroup group, int httpCode) { - return new HttpSource(group, httpCode, null, false); + Measurand httpAlt(HttpGroup group, int httpCode) { + this.httpSource = new HttpSource(group, httpCode, null, true); + return this; } - static HttpSource http(HttpGroup group, String key) { - return new HttpSource(group, null, key, false); + Measurand channelType(ChannelTypeUID channelTypeUID) { + this.channelTypeUID = channelTypeUID; + return this; } - 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 map = HTTP_LOOKUP.get(group); + Measurand customization(ParserCustomizationType type, MeasureType measureType) { + Map map = customizations; if (map == null) { - return null; + map = new HashMap<>(); + customizations = map; } - return map.get(normalizeId(id)); + map.put(type, new ParserCustomization(type, measureType)); + return this; } - 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)); + @Nullable + HttpSource getHttpSource() { + return httpSource; } - Measurand(String channelId, int code, String name, MeasureType measureType, ParserCustomization... customizations) { - this(channelId, code, name, measureType, (ChannelTypeUID) null, customizations); + String getChannelPrefix() { + return channelPrefix; } - 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, + @Override + public int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel, @Nullable ParserCustomizationType customizationType, List result, DebugDetails debugDetails) { - int subOffset = 0; - for (Parser parser : parsers) { - subOffset += parser.extractMeasuredValues(data, offset + subOffset, channel, customizationType, result, - debugDetails); + MeasureType measureType = getMeasureType(customizationType); + State state = measureType.toState(data, offset); + if (state != null) { + debugDetails.addDebugDetails(offset, measureType.getByteSize(), + measureType.name() + ": " + state.toFullString()); + ChannelTypeUID channelType = channelTypeUID == null ? measureType.getChannelTypeId() : channelTypeUID; + result.add(new MeasuredValue(measureType, channelPrefix, channel, channelType, state, name)); + } else { + debugDetails.addDebugDetails(offset, measureType.getByteSize(), measureType.name() + ": null"); } - return subOffset; + return measureType.getByteSize(); } - private interface Parser { - int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel, - @Nullable ParserCustomizationType customizationType, List 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 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() { + public MeasureType getMeasureType(@Nullable ParserCustomizationType customizationType) { + if (customizationType == null) { return measureType; } + return Objects.requireNonNull(Optional.ofNullable(customizations).map(m -> m.get(customizationType)) + .map(ParserCustomization::getMeasureType).orElse(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 result, DebugDetails debugDetails) { - return measurand.extractMeasuredValues(data, offset, channel, customizationType, result, debugDetails); - } - - public String getDebugString() { - return measurand.name() + (channel == null ? "" : " channel " + channel); + @Nullable + MeasuredValue parseHttp(String val, @Nullable String unit, @Nullable Integer channel, + @Nullable ParserCustomizationType customizationType) { + MeasureType measureType = getMeasureType(customizationType); + State state = measureType.parseState(val, unit); + if (state == null) { + return null; } + ChannelTypeUID channelType = channelTypeUID == null ? measureType.getChannelTypeId() : channelTypeUID; + return new MeasuredValue(measureType, channelPrefix, channel, channelType, state, name); } - private static class MeasurandParser implements Parser { - private final String name; - private final String channelPrefix; - private final MeasureType measureType; - - private final @Nullable Map customizations; - private final @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) { - 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))); - } - } - - @Override - public int extractMeasuredValues(byte[] data, int offset, @Nullable Integer channel, - @Nullable ParserCustomizationType customizationType, List result, - DebugDetails debugDetails) { - MeasureType measureType = getMeasureType(customizationType); - State state = measureType.toState(data, offset); - if (state != null) { - debugDetails.addDebugDetails(offset, measureType.getByteSize(), - measureType.name() + ": " + state.toFullString()); - ChannelTypeUID channelType = channelTypeUID == null ? measureType.getChannelTypeId() : channelTypeUID; - result.add(new MeasuredValue(measureType, channelPrefix, channel, channelType, state, name)); - } else { - debugDetails.addDebugDetails(offset, measureType.getByteSize(), measureType.name() + ": null"); - } - return measureType.getByteSize(); - } - - public MeasureType getMeasureType(@Nullable ParserCustomizationType customizationType) { - if (customizationType == null) { - return measureType; - } - return Objects.requireNonNull(Optional.ofNullable(customizations).map(m -> m.get(customizationType)) - .map(ParserCustomization::getMeasureType).orElse(measureType)); - } - - @Nullable - MeasuredValue parseHttp(String val, @Nullable String unit, @Nullable Integer channel, - @Nullable ParserCustomizationType customizationType) { - MeasureType measureType = getMeasureType(customizationType); - State state = measureType.parseState(val, unit); - if (state == null) { - return null; - } - 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); } } diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandGroup.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandGroup.java new file mode 100644 index 0000000000..5dde0d169b --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandGroup.java @@ -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 result, + DebugDetails debugDetails) { + int subOffset = 0; + for (Parser slot : slots) { + subOffset += slot.extractMeasuredValues(data, offset + subOffset, channel, customizationType, result, + debugDetails); + } + return subOffset; + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistry.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistry.java new file mode 100644 index 0000000000..d9036805aa --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistry.java @@ -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 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> byHttp; + + private MeasurandRegistry(Map byCode, Map> 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 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 byCode = new HashMap<>(); + private final Map> 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 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> frozenHttp = new HashMap<>(); + byHttp.forEach((group, bindings) -> frozenHttp.put(group, Map.copyOf(bindings))); + return new MeasurandRegistry(Map.copyOf(byCode), Map.copyOf(frozenHttp)); + } + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Parser.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Parser.java new file mode 100644 index 0000000000..90b57af612 --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Parser.java @@ -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 result, DebugDetails debugDetails); +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomization.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomization.java new file mode 100644 index 0000000000..f3f1769116 --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomization.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomizationType.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomizationType.java new file mode 100644 index 0000000000..a9b241d460 --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/ParserCustomizationType.java @@ -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 +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Protocol.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Protocol.java index 4845678ce8..75e0b01fca 100644 --- a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Protocol.java +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Protocol.java @@ -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; } diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Skip.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Skip.java new file mode 100644 index 0000000000..c9219c7f3e --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/Skip.java @@ -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 result, + DebugDetails debugDetails) { + debugDetails.addDebugDetails(offset, skip, "skipped"); + return skip; + } +} diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/EcowittDataParser.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/EcowittDataParser.java index c39863d986..169ca59401 100644 --- a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/EcowittDataParser.java +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/EcowittDataParser.java @@ -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. *

- * 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 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; diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/FineOffsetDataParser.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/FineOffsetDataParser.java index 635f834e12..1360a2388e 100644 --- a/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/FineOffsetDataParser.java +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/main/java/org/openhab/binding/fineoffsetweatherstation/internal/service/FineOffsetDataParser.java @@ -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 getRainData(byte[] data, DebugDetails debugDetails) { - return readMeasuredValues(data, 5, Measurand.ParserCustomizationType.RAIN_READING, debugDetails); + return readMeasuredValues(data, 5, ParserCustomizationType.RAIN_READING, debugDetails); } - private List readMeasuredValues(byte[] data, int idx, - Measurand.@Nullable ParserCustomizationType protocol, DebugDetails debugDetails) { + private List readMeasuredValues(byte[] data, int idx, @Nullable ParserCustomizationType protocol, + DebugDetails debugDetails) { var size = toUInt16(data, 3); List 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"); diff --git a/bundles/org.openhab.binding.fineoffsetweatherstation/src/test/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistryTest.java b/bundles/org.openhab.binding.fineoffsetweatherstation/src/test/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistryTest.java new file mode 100644 index 0000000000..9a3322d83a --- /dev/null +++ b/bundles/org.openhab.binding.fineoffsetweatherstation/src/test/java/org/openhab/binding/fineoffsetweatherstation/internal/domain/MeasurandRegistryTest.java @@ -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(); + } +}