Skip to content

Commit 1391232

Browse files
committed
DAQ: Add decoder for Fine Offset (FOSHK) weather station equipment
Through the excellent `ecowitt2mqtt` machinery [1], this supports any weather station/gateway that is produced by Shenzhen Fine Offset Electronics Co., Ltd. [2] aka. Fine Offset aka. OFFSET. This includes brands that white-label Fine Offset equipment, such as: - Ambient Weather (U.S.) - Ecowitt (China, Hong Kong) - Froggit (Germany) By default, `ecowitt2mqtt` is configured to output data in the "metric" unit system. [1] https://github.com/bachya/ecowitt2mqtt [2] https://www.foshk.com/
1 parent 6ff6922 commit 1391232

File tree

9 files changed

+295
-18
lines changed

9 files changed

+295
-18
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ in progress
1212
mean iterator type: *query.stringInterruptIterator`` or ``InfluxDB Error:
1313
not executed``.
1414
- DAQ: Mask ``PASSKEY`` variable coming from HTTP, emitted by Ecowitt
15+
- DAQ: Add adapter/decoder for Fine Offset weather station equipment,
16+
with white-label products by Ambient Weather, Ecowitt, and Froggit.
17+
Configure it to output data in "metric" unit system by default.
1518

1619

1720
.. _kotori-0.27.0:

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ virtualenv-docs: setup-virtualenv
3333
# Install requirements for development.
3434
virtualenv-dev: setup-virtualenv
3535
@$(pip) install --upgrade --prefer-binary --requirement=requirements-test.txt
36-
@$(pip) install --upgrade --prefer-binary --editable=.[daq,daq_geospatial,export,scientific,firmware]
36+
@$(pip) install --upgrade --prefer-binary --editable=.[daq,daq_geospatial,daq_fineoffset,export,scientific,firmware]
3737

3838
# Install requirements for releasing.
3939
install-releasetools: setup-virtualenv

doc/source/setup/python-package.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Kotori releases are published to https://pypi.org/project/kotori/ ::
5454
pip install --user kotori[daq,export]
5555

5656
# Install more extra features
57-
pip install --user kotori[daq,daq_geospatial,export,plotting,scientific,firmware]
57+
pip install --user kotori[daq,daq_geospatial,daq_fineoffset,export,plotting,scientific,firmware]
5858

5959
# Install particular version
6060
pip install --user kotori[daq,export]==0.26.6

kotori/daq/decoder/fineoffset.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# -*- coding: utf-8 -*-
2+
# (c) 2023 Andreas Motl <[email protected]>
3+
import typing as t
4+
5+
6+
class FineOffsetDecoder:
7+
"""
8+
Decode data format submitted by Fine Offset (FOSHK) weather stations, using the
9+
excellent `ecowitt2mqtt` machinery [1].
10+
11+
By wrapping it into a Kotori decoder, it will simplify operation and maintenance.
12+
Effectively, there are fewer moving parts involved, yet all features can be leveraged:
13+
14+
- anonymization of data
15+
- convenience of unit conversion
16+
- additional calculated values
17+
- integrated test coverage
18+
- no installation overhead
19+
20+
Despite the name of the library, `ecowitt2mqtt` [2] supports any weather
21+
station/gateway that is produced by Shenzhen Fine Offset Electronics Co., Ltd. [3]
22+
aka. Fine Offset aka. OFFSET. This includes brands that white-label Fine Offset
23+
equipment, such as:
24+
25+
- Ambient Weather (U.S.)
26+
- Ecowitt (China, Hong Kong)
27+
- Froggit (Germany)
28+
29+
...and many others. For more information on how these brands relate to one another,
30+
see the forum post at [4].
31+
32+
Although there are some small differences between how these various branded devices
33+
are configured, `ecowitt2mqtt` endeavors to incorporate them all with minimal effort
34+
on the user's part [5].
35+
36+
`ecowitt2mqtt` currently supports the following input data formats [6]:
37+
38+
- `ambient_weather`
39+
- `ecowitt`
40+
41+
[1] https://community.hiveeyes.org/t/more-data-acquisition-payload-formats-for-kotori/1421/17
42+
[2] https://github.com/bachya/ecowitt2mqtt
43+
[3] https://www.foshk.com/
44+
[4] https://www.wxforum.net/index.php?topic=40730.0
45+
[5] https://github.com/bachya/ecowitt2mqtt/tree/dev#supported-brands
46+
[6] https://github.com/bachya/ecowitt2mqtt/tree/dev#input-data-formats
47+
48+
Example data
49+
============
50+
51+
This is an input data sample provided by an Ecowitt weather station, then
52+
converted to the "metric" unit system and with additional computed values
53+
by `ecowitt2mqtt`, displayed in the "Output" section.
54+
55+
Input
56+
-----
57+
::
58+
59+
{
60+
"PASSKEY": "B950C...[obliterated]",
61+
"stationtype": "EasyWeatherPro_V5.0.6",
62+
"runtime": "456128",
63+
"dateutc": "2023-02-20 16:02:19",
64+
"tempinf": "69.8",
65+
"humidityin": "47",
66+
"baromrelin": "29.713",
67+
"baromabsin": "29.713",
68+
"tempf": "48.4",
69+
"humidity": "80",
70+
"winddir": "108",
71+
"windspeedmph": "1.12",
72+
"windgustmph": "4.92",
73+
"maxdailygust": "12.97",
74+
"solarradiation": "1.89",
75+
"uv": "0",
76+
"rainratein": "0.000",
77+
"eventrainin": "0.000",
78+
"hourlyrainin": "0.000",
79+
"dailyrainin": "0.028",
80+
"weeklyrainin": "0.098",
81+
"monthlyrainin": "0.909",
82+
"yearlyrainin": "0.909",
83+
"temp1f": "45.0",
84+
"humidity1": "90",
85+
"soilmoisture1": "46",
86+
"soilmoisture2": "53",
87+
"tf_ch1": "41.9",
88+
"rrain_piezo": "0.000",
89+
"erain_piezo": "0.000",
90+
"hrain_piezo": "0.000",
91+
"drain_piezo": "0.028",
92+
"wrain_piezo": "0.043",
93+
"mrain_piezo": "0.492",
94+
"yrain_piezo": "0.492",
95+
"wh65batt": "0",
96+
"wh25batt": "0",
97+
"batt1": "0",
98+
"soilbatt1": "1.6",
99+
"soilbatt2": "1.6",
100+
"tf_batt1": "1.60",
101+
"wh90batt": "3.04",
102+
"freq": "868M",
103+
"model": "HP1000SE-PRO_Pro_V1.8.5",
104+
}
105+
106+
Output
107+
------
108+
::
109+
110+
{
111+
"runtime": 456128.0,
112+
"tempin": 20.999999999999996,
113+
"humidityin": 47.0,
114+
"baromrel": 1006.1976567045213,
115+
"baromabs": 1006.1976567045213,
116+
"temp": 9.11111111111111,
117+
"humidity": 80.0,
118+
"winddir": 108.0,
119+
"windspeed": 1.8024652800000003,
120+
"windgust": 7.91797248,
121+
"maxdailygust": 20.873191679999998,
122+
"solarradiation": 1.89,
123+
"uv": 0.0,
124+
"rainrate": 0.0,
125+
"eventrain": 0.0,
126+
"hourlyrain": 0.0,
127+
"dailyrain": 0.7112,
128+
"weeklyrain": 2.4892000000000003,
129+
"monthlyrain": 23.0886,
130+
"yearlyrain": 23.0886,
131+
"temp1": 7.222222222222222,
132+
"humidity1": 90.0,
133+
"soilmoisture1": 46.0,
134+
"soilmoisture2": 53.0,
135+
"tf_ch1": 5.499999999999999,
136+
"rrain_piezo": 0.0,
137+
"erain_piezo": 0.0,
138+
"hrain_piezo": 0.0,
139+
"drain_piezo": 0.7112,
140+
"wrain_piezo": 1.0921999999999998,
141+
"mrain_piezo": 12.4968,
142+
"yrain_piezo": 12.4968,
143+
"wh65batt": "OFF",
144+
"wh25batt": "OFF",
145+
"batt1": "OFF",
146+
"soilbatt1": 1.6,
147+
"soilbatt2": 1.6,
148+
"tf_batt1": 1.6,
149+
"wh90batt": 3.04,
150+
"beaufortscale": 1,
151+
"dewpoint": 5.846942096976985,
152+
"feelslike": 9.11111111111111,
153+
"frostpoint": 4.706401162443284,
154+
"frostrisk": "No risk",
155+
"heatindex": 8.166666666666668,
156+
"humidex": 9,
157+
"humidex_perception": "Comfortable",
158+
"humidityabs": 7.101409765339333,
159+
"humidityabsin": 7.101409765339333,
160+
"relative_strain_index": null,
161+
"relative_strain_index_perception": null,
162+
"safe_exposure_time_skin_type_1": null,
163+
"safe_exposure_time_skin_type_2": null,
164+
"safe_exposure_time_skin_type_3": null,
165+
"safe_exposure_time_skin_type_4": null,
166+
"safe_exposure_time_skin_type_5": null,
167+
"safe_exposure_time_skin_type_6": null,
168+
"simmerindex": null,
169+
"simmerzone": null,
170+
"solarradiation_perceived": 47.57669425765605,
171+
"thermalperception": "Dry",
172+
"windchill": null
173+
}
174+
175+
"""
176+
177+
@staticmethod
178+
def detect(data: t.Dict[str, str]) -> bool:
179+
"""
180+
Determine whether the data payload is submitted by a Fine Offset device.
181+
182+
TODO: Maybe leverage field names in `ecowitt2mqtt.data.DEFAULT_KEYS_TO_IGNORE`?
183+
"""
184+
return "PASSKEY" in data and "stationtype" in data and "model" in data
185+
186+
@staticmethod
187+
def decode(data: t.Dict[str, str]) -> t.Dict[str, t.Any]:
188+
"""
189+
Decode data payload submitted by a Fine Offset device, using `ecowitt2mqtt`.
190+
"""
191+
192+
# This variant is compatible with modern releases like `ecowitt2mqtt-2023.2.1`.
193+
try:
194+
from ecowitt2mqtt.config import Config
195+
from ecowitt2mqtt.data import ProcessedData
196+
197+
config = Config(
198+
{
199+
# Both configuration variables are currently *required* by `ecowitt2mqtt`.
200+
# Fortunately, `mqtt_broker` can be left empty.
201+
# TODO: Can this be improved if upstream would accept a corresponding patch?
202+
"hass_discovery": True,
203+
"mqtt_broker": "",
204+
# Output values in *metric* unit system by default.
205+
# TODO: Make output unit system configurable.
206+
"output_unit_system": "metric",
207+
}
208+
)
209+
processed_data = ProcessedData(config=config, data=data)
210+
data = {key: value.value for key, value in processed_data.output.items()}
211+
212+
# This variant is compatible with earlier releases like `ecowitt2mqtt-2022.5.0`.
213+
except ImportError:
214+
import argparse
215+
216+
from ecowitt2mqtt.data import DataProcessor
217+
218+
args = argparse.Namespace()
219+
args.input_unit_system = "imperial"
220+
args.output_unit_system = "metric"
221+
args.raw_data = None
222+
223+
data_processor = DataProcessor(data, args)
224+
data = data_processor.generate_data()
225+
226+
return data

kotori/io/protocol/http.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from twisted.web.server import Site
2121
from twisted.web.error import Error
2222
from twisted.python.compat import nativeString
23+
24+
from kotori.daq.decoder.fineoffset import FineOffsetDecoder
2325
from kotori.io.router.path import PathRoutingEngine
2426
from kotori.io.export.tabular import UniversalTabularExporter
2527
from kotori.io.export.plot import UniversalPlotter
@@ -330,11 +332,10 @@ def read_request(self, bucket):
330332
if request.method == 'POST':
331333
data = self.data_acquisition(bucket)
332334

333-
# Mask `PASSKEY` ingress variable.
334-
# https://github.com/daq-tools/kotori/discussions/122
335-
# https://community.hiveeyes.org/t/ecowitt-wunderground-api-fur-weather-hiveeyes-org-nutzbar/4735
336-
if "PASSKEY" in data:
337-
del data["PASSKEY"]
335+
# Decode data from specific devices.
336+
# TODO: Handle decoding data from specific devices in a more generic way.
337+
if FineOffsetDecoder.detect(data):
338+
data = FineOffsetDecoder.decode(data)
338339

339340
return data
340341

packaging/wheels/build.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ function invoke_build() {
1717
flavor=$1
1818

1919
if [ $flavor = "full" ]; then
20-
extras="daq,daq_geospatial,export,plotting,firmware,scientific"
20+
extras="daq,daq_geospatial,daq_fineoffset,export,plotting,firmware,scientific"
2121
elif [ $flavor = "standard" ]; then
22-
extras="daq,daq_geospatial,export"
22+
extras="daq,daq_geospatial,daq_fineoffset,export"
2323
else
2424
echo "ERROR: Package flavor '${flavor}' unknown or not implemented"
2525
exit 1

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@
8888
'tabulate==0.7.5', # 0.8.2
8989
'sympy==0.7.6.1', # 1.1.1
9090
],
91+
'daq_fineoffset': [
92+
'ecowitt2mqtt<=2023.02.1'
93+
],
9194
'storage_plus': [
9295
'alchimia>=0.4,<1',
9396
],

tasks/packaging/model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ def resolve(self):
8383
self.features = "daq"
8484
elif self.flavor == "standard":
8585
self.name = "kotori-standard"
86-
self.features = "daq,daq_geospatial,export"
86+
self.features = "daq,daq_geospatial,daq_fineoffset,export"
8787
elif self.flavor == "full":
8888
self.name = "kotori"
89-
self.features = "daq,daq_geospatial,export,plotting,firmware,scientific"
89+
self.features = "daq,daq_geospatial,daq_fineoffset,export,plotting,firmware,scientific"
9090
else:
9191
raise ValueError("Unknown package flavor")
9292

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
import json
2-
from test.settings.mqttkit import PROCESS_DELAY_HTTP, influx_sensors, settings
2+
import logging
3+
4+
from test.settings.mqttkit import PROCESS_DELAY_HTTP, influx_sensors, settings, grafana
35
from test.util import http_form_sensor, sleep
46

57
import pytest
68
import pytest_twisted
79
from twisted.internet import threads
810

911

12+
logger = logging.getLogger(__name__)
13+
14+
1015
@pytest_twisted.inlineCallbacks
1116
@pytest.mark.http
12-
def test_ecowitt_post(machinery, create_influxdb, reset_influxdb):
17+
@pytest.mark.grafana
18+
def test_device_ecowitt_post(machinery, create_influxdb, reset_influxdb, reset_grafana):
1319
"""
1420
Submit single reading in ``x-www-form-urlencoded`` format to HTTP API
1521
and proof it is stored in the InfluxDB database.
1622
"""
1723

18-
# Submit a single measurement, without timestamp.
24+
# Submit a single measurement.
1925
data = {
2026
"PASSKEY": "B950C...[obliterated]",
2127
"stationtype": "EasyWeatherPro_V5.0.6",
@@ -78,11 +84,49 @@ def test_ecowitt_post(machinery, create_influxdb, reset_influxdb):
7884
# Proof that data arrived in InfluxDB.
7985
record = influx_sensors.get_first_record()
8086

81-
assert record["tempf"] == 48.4
87+
# Standard values, converted to "metric" unit system.
88+
# Temperature converted from 48.4 degrees Fahrenheit, wind speed converted
89+
# from 1.12 mph, humidity untouched.
90+
assert round(record["temp"], 1) == 9.1
91+
assert round(record["windspeed"], 1) == 1.8
8292
assert record["humidity"] == 80.0
83-
assert record["model"] == "HP1000SE-PRO_Pro_V1.8.5"
8493

85-
# Make sure this will not be public.
94+
# Verify the data includes additional computed fields.
95+
assert round(record["dewpoint"], 1) == 5.8
96+
assert round(record["feelslike"], 1) == 9.1
97+
98+
# Only newer releases of `ecowitt2mqtt` will compute those fields.
99+
if "frostpoint" in record:
100+
assert round(record["frostpoint"], 1) == 4.7
101+
assert record["frostrisk"] == "No risk"
102+
assert record["thermalperception"] == "Dry"
103+
104+
# Make sure those fields got purged, so they don't leak into public data.
86105
assert "PASSKEY" not in record
106+
assert "stationtype" not in record
107+
assert "model" not in record
108+
109+
# Timestamp field also gets removed, probably to avoid ambiguities.
110+
assert "dateutc" not in record
111+
112+
# Proof that Grafana is well provisioned.
113+
logger.info('Grafana: Checking datasource')
114+
assert settings.influx_database in grafana.get_datasource_names()
115+
116+
logger.info('Grafana: Retrieving dashboard')
117+
dashboard_name = settings.grafana_dashboards[0]
118+
dashboard = grafana.get_dashboard_by_name(dashboard_name)
119+
120+
logger.info('Grafana: Checking dashboard layout')
121+
targets = dashboard["rows"][0]["panels"][0]["targets"]
122+
assert targets[0]["measurement"] == settings.influx_measurement_sensors
123+
124+
fieldnames = []
125+
for target in targets:
126+
fieldnames.append(target.get("fields")[0]["name"])
87127

88-
yield record
128+
# Verify that text fields are not part of the graph. Otherwise, Grafana would croak like:
129+
# - InfluxDB Error: unsupported mean iterator type: *query.stringInterruptIterator
130+
# - InfluxDB Error: not executed
131+
assert "batt1" not in fieldnames, "'batt1' should have been removed, because it is a text field"
132+
assert "frostrisk" not in fieldnames, "'frostrisk' should have been removed, because it is a text field"

0 commit comments

Comments
 (0)