From 05407bb19e908469283da4ad15e9c3b7134ff5c3 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sun, 5 Oct 2025 11:34:26 +0100 Subject: [PATCH] Prints output only --- .gitignore | 216 ++++++++ meteostick.py | 1352 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1568 insertions(+) create mode 100644 .gitignore create mode 100644 meteostick.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e15106e --- /dev/null +++ b/.gitignore @@ -0,0 +1,216 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/meteostick.py b/meteostick.py new file mode 100644 index 0000000..4e068cd --- /dev/null +++ b/meteostick.py @@ -0,0 +1,1352 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Meteostick driver for weewx +# +# Copyright 2016-2020 Matthew Wall, Luc Heijst +# +# Thanks to Frank Bandle for testing during the development of this driver. +# Thanks to kobuki for validation, testing, and general sanity checks. +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. +# +# See http://www.gnu.org/licenses/ + +"""Meteostick is a USB device that receives radio transmissions from Davis +weather stations. + +The meteostick has a preset radio frequency (RF) treshold value which is twice +the RF sensity value in dB. Valid values for RF sensity range from 0 to 125. +Both positive and negative parameter values will be treated as the +same actual (negative) dB values. + +The default RF sensitivity value is 90 (-90 dB). Values between 95 and 125 +tend to give too much noise and false readings (the higher value the more +noise). Values lower than 50 likely result in no readings at all. + +The meteostick outputs data in one of 3 formats: human-readable, machine, and +raw. The machine format is, in fact, human-readable as well. This driver +supports only the raw format. The raw format provides more data and +seems to result in higher quality readings. +""" + +# FIXME: eliminate the service component - there is no need to bind to events +from __future__ import division +from __future__ import print_function # Python 2/3 compatiblity +from __future__ import with_statement + +import math +import serial +import string +import time + +import weewx +import weewx.drivers +import weewx.engine +import weewx.wxformulas +import weewx.units +from weewx.crc16 import crc16 + +try: + # Test for new-style weewx logging by trying to import weeutil.logger + import weeutil.logger + import logging + log = logging.getLogger(__name__) + + def logdbg(msg): + log.debug(msg) + + def loginf(msg): + log.info(msg) + + def logerr(msg): + log.error(msg) + +except ImportError: + # Old-style weewx logging + import syslog + + def logmsg(level, msg): + syslog.syslog(level, 'mstk: %s:' % msg) + + def logdbg(msg): + logmsg(syslog.LOG_DEBUG, msg) + + def loginf(msg): + logmsg(syslog.LOG_INFO, msg) + + def logerr(msg): + logmsg(syslog.LOG_ERR, msg) + +DRIVER_NAME = 'Meteostick' +DRIVER_VERSION = '0.67' + +DEBUG_SERIAL = 0 +DEBUG_RAIN = 0 +DEBUG_PARSE = 0 +DEBUG_RFS = 0 + +MPH_TO_MPS = 1609.34 / 3600.0 # meter/mile * hour/second + +def loader(config_dict, engine): + return MeteostickDriver(engine, config_dict) + +def confeditor_loader(): + return MeteostickConfEditor() + +def configurator_loader(config_dict): + return MeteostickConfigurator() + + +def dbg_serial(verbosity, msg): + if DEBUG_SERIAL >= verbosity: + logdbg(msg) + +def dbg_parse(verbosity, msg): + if DEBUG_PARSE >= verbosity: + logdbg(msg) + +def _fmt(data): + if not data: + return '' + return ' '.join(['%02x' % int(ord(x)) for x in data]) + +# default temperature for soil moisture and leaf wetness sensors that +# do not have a temperature sensor. +# Also used to normalize raw values for a standard temperature. +DEFAULT_SOIL_TEMP = 24 # C + +RAW = 0 # indices of table with raw values +POT = 1 # indices of table with potentials + +# Lookup table for soil_moisture_raw values to get a soil_moisture value based +# upon a linear formula. Correction factor = 0.009 +SM_MAP = {RAW: ( 99.2, 140.1, 218.7, 226.9, 266.8, 391.7, 475.6, 538.2, 596.1, 673.7, 720.1), + POT: ( 0.0, 1.0, 9.0, 10.0, 15.0, 35.0, 55.0, 75.0, 100.0, 150.0, 200.0)} + +# Lookup table for leaf_wetness_raw values to get a leaf_wetness value based +# upon a linear formula. Correction factor = 0.0 +LW_MAP = {RAW: (857.0, 864.0, 895.0, 911.0, 940.0, 952.0, 991.0, 1013.0), + POT: ( 15.0, 14.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0)} + + +def calculate_thermistor_temp(temp_raw): + """ Decode the raw thermistor temperature, then calculate the actual + thermistor temperature and the leaf_soil potential, using Davis' formulas. + see: https://github.com/cmatteri/CC1101-Weather-Receiver/wiki/Soil-Moisture-Station-Protocol + :param temp_raw: raw value from sensor for leaf wetness and soil moisture + """ + + # Convert temp_raw to a resistance (R) in kiloOhms + a = 18.81099 + b = 0.0009988027 + r = a / (1.0 / temp_raw - b) / 1000 # k ohms + + # Steinhart-Hart parameters + s1 = 0.002783573 + s2 = 0.0002509406 + try: + thermistor_temp = 1 / (s1 + s2 * math.log(r)) - 273 + dbg_parse(3, 'r (k ohm) %s temp_raw %s thermistor_temp %s' % + (r, temp_raw, thermistor_temp)) + return thermistor_temp + except ValueError as e: + logerr('thermistor_temp failed for temp_raw %s r (k ohm) %s' + 'error: %s' % (temp_raw, r, e)) + return DEFAULT_SOIL_TEMP + + +def lookup_potential(sensor_name, norm_fact, sensor_raw, sensor_temp, lookup): + """Look up potential based upon a normalized raw value (i.e. temp corrected + for DEFAULT_SOIL_TEMP) and a linear function between two points in the + lookup table. + :param lookup: a table with both sensor_raw_norm values and corresponding + potential values. the table is composed for a specific + norm-factor. + :param sensor_temp: sensor temp in C + :param sensor_raw: sensor raw potential value + :param norm_fact: temp correction factor for normalizing sensor-raw values + :param sensor_name: string used in debug messages + """ + + # normalize raw value for standard temperature (DEFAULT_SOIL_TEMP) + sensor_raw_norm = sensor_raw * (1 + norm_fact * (sensor_temp - DEFAULT_SOIL_TEMP)) + + numcols = len(lookup[RAW]) + if sensor_raw_norm >= lookup[RAW][numcols - 1]: + potential = lookup[POT][numcols - 1] # preset potential to last value + dbg_parse(3, "%s: temp=%s fact=%s raw=%s norm=%s potential=%s >= RAW=%s" % + (sensor_name, sensor_temp, norm_fact, sensor_raw, + sensor_raw_norm, potential, lookup[RAW][numcols - 1])) + else: + potential = lookup[POT][0] # preset potential to first value + # lookup sensor_raw_norm value in table + for x in range(0, numcols): + if sensor_raw_norm < lookup[RAW][x]: + if x == 0: + # 'pre zero' phase; potential = first value + dbg_parse(3, "%s: temp=%s fact=%s raw=%s norm=%s potential=%s < RAW=%s" % + (sensor_name, sensor_temp, norm_fact, sensor_raw, + sensor_raw_norm, potential, lookup[RAW][0])) + break + else: + # determine the potential value + potential_per_raw = (lookup[POT][x] - lookup[POT][x - 1]) / (lookup[RAW][x] - lookup[RAW][x - 1]) + potential_offset = (sensor_raw_norm - lookup[RAW][x - 1]) * potential_per_raw + potential = lookup[POT][x - 1] + potential_offset + dbg_parse(3, "%s: temp=%s fact=%s raw=%s norm=%s potential=%s RAW=%s to %s POT=%s to %s " % + (sensor_name, sensor_temp, norm_fact, sensor_raw, + sensor_raw_norm, potential, + lookup[RAW][x - 1], lookup[RAW][x], + lookup[POT][x - 1], lookup[POT][x])) + break + return potential + + +RAW_CHANNEL = 0 # unused channel for the receiver stats in raw format + + +class MeteostickDriver(weewx.drivers.AbstractDevice, weewx.engine.StdService): + NUM_CHAN = 10 # 8 channels, one fake channel (9), one unused channel (0) + DEFAULT_RAIN_BUCKET_TYPE = 1 + DEFAULT_SENSOR_MAP = { + 'pressure': 'pressure', + 'inTemp': 'temp_in', # temperature inside meteostick + 'windSpeed': 'wind_speed', + 'windDir': 'wind_dir', + 'outTemp': 'temperature', + 'outHumidity': 'humidity', + 'inHumidity': 'humidity_in', + # To use a rainRate calculation from this driver that closely matches + # that of a Davis station, uncomment the rainRate field then specify + # rainRate = hardware in section [StdWXCalculate] of weewx.conf + #'rainRate': 'rain_rate', + 'radiation': 'solar_radiation', + 'UV': 'uv', + 'rxCheckPercent': 'pct_good', + 'soilTemp1': 'soil_temp_1', + 'soilTemp2': 'soil_temp_2', + 'soilTemp3': 'soil_temp_3', + 'soilTemp4': 'soil_temp_4', + 'soilMoist1': 'soil_moisture_1', + 'soilMoist2': 'soil_moisture_2', + 'soilMoist3': 'soil_moisture_3', + 'soilMoist4': 'soil_moisture_4', + 'leafWet1': 'leaf_wetness_1', + 'leafWet2': 'leaf_wetness_2', + 'leafTemp1': 'leaf_temp_1', + 'leafTemp2': 'leaf_temp_2', + 'extraTemp1': 'temp_1', + 'extraTemp2': 'temp_2', + 'extraTemp3': 'temp_3', + 'extraHumid1': 'humid_1', + 'extraHumid2': 'humid_2', + 'txBatteryStatus': 'bat_iss', + 'windBatteryStatus': 'bat_anemometer', + 'rainBatteryStatus': 'bat_leaf_soil', + 'outTempBatteryStatus': 'bat_th_1', + 'inTempBatteryStatus': 'bat_th_2', + 'referenceVoltage': 'solar_power', + 'supplyVoltage': 'supercap_volt'} + + def __init__(self, engine, config_dict): + loginf('driver version is %s' % DRIVER_VERSION) + if engine: + weewx.engine.StdService.__init__(self, engine, config_dict) + stn_dict = config_dict.get(DRIVER_NAME, {}) + + global DEBUG_PARSE + DEBUG_PARSE = int(stn_dict.get('debug_parse', DEBUG_PARSE)) + global DEBUG_SERIAL + DEBUG_SERIAL = int(stn_dict.get('debug_serial', DEBUG_SERIAL)) + global DEBUG_RAIN + DEBUG_RAIN = int(stn_dict.get('debug_rain', DEBUG_RAIN)) + global DEBUG_RFS + DEBUG_RFS = int(stn_dict.get('debug_rf_sensitivity', DEBUG_RFS)) + + bucket_type = int(stn_dict.get('rain_bucket_type', + self.DEFAULT_RAIN_BUCKET_TYPE)) + if bucket_type not in [0, 1]: + raise ValueError("unsupported rain bucket type %s" % bucket_type) + self.rain_per_tip = 0.254 if bucket_type == 0 else 0.2 # mm + loginf('using rain_bucket_type %s' % bucket_type) + self.sensor_map = dict(self.DEFAULT_SENSOR_MAP) + if 'sensor_map' in stn_dict: + self.sensor_map.update(stn_dict['sensor_map']) + loginf('sensor map is: %s' % self.sensor_map) + self.max_tries = int(stn_dict.get('max_tries', 10)) + self.retry_wait = int(stn_dict.get('retry_wait', 10)) + self.last_rain_count = None + self.first_rf_stats = True + self._init_rf_stats() + + self.station = Meteostick(**stn_dict) + self.station.open() + self.station.reset() + self.station.configure() + + # bind to new archive record events so that we can update the rf + # stats on each archive record. + if engine: + self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) + + def closePort(self): + if self.station is not None: + self.station.close() + self.station = None + + @property + def hardware_name(self): + return 'Meteostick' + + def genLoopPackets(self): + while True: + readings = self.station.get_readings_with_retry(self.max_tries, + self.retry_wait) + data = self.station.parse_readings(readings, self.rain_per_tip) + if 'channel' in data: + self._update_rf_stats(data['channel'], data['rf_signal'], + data['rf_missed']) + if data: + dbg_parse(2, "data: %s" % data) + packet = self._data_to_packet(data) + if packet is not None: + dbg_parse(3, "packet: %s" % packet) + yield packet + + def _data_to_packet(self, data): + packet = dict() + # map sensor observations to database field names + for k in self.sensor_map: + if self.sensor_map[k] in data: + packet[k] = data[self.sensor_map[k]] + # convert the rain count to a rain delta measure + if 'rain_count' in data: + if self.last_rain_count is not None: + rain_count = data['rain_count'] - self.last_rain_count + else: + rain_count = 0 + # handle rain counter wrap around from 127 to 0 + if rain_count < 0: + if DEBUG_RAIN: + logdbg("rain counter wraparound detected rain_count=%s" % + rain_count) + rain_count += 128 + self.last_rain_count = data['rain_count'] + packet['rain'] = float(rain_count) * self.rain_per_tip + if DEBUG_RAIN: + logdbg("rain=%s rain_count=%s last_rain_count=%s" % + (packet['rain'], rain_count, self.last_rain_count)) + elif len(packet) <= 1: + # No data found + dbg_parse(3, "skip packet for data: %s" % data) + return None + packet['dateTime'] = int(time.time() + 0.5) + packet['usUnits'] = weewx.METRICWX + return packet + + def _init_rf_stats(self): + self.rf_stats = { + 'min': [0] * self.NUM_CHAN, # rf sensitivity has negative values + 'max': [-125] * self.NUM_CHAN, + 'sum': [0] * self.NUM_CHAN, + 'cnt': [0] * self.NUM_CHAN, + 'last': [0] * self.NUM_CHAN, + 'avg': [0] * self.NUM_CHAN, + 'missed': [0] * self.NUM_CHAN, + 'pctgood': [None] * self.NUM_CHAN, + 'ts': int(time.time())} + # unlike the rf sensitivity measures, pct_good is positive + + def _update_rf_stats(self, ch, signal, missed): + # update the rf statistics + self.rf_stats['min'][ch] = min(signal, self.rf_stats['min'][ch]) + self.rf_stats['max'][ch] = max(signal, self.rf_stats['max'][ch]) + self.rf_stats['sum'][ch] += signal + self.rf_stats['cnt'][ch] += 1 + self.rf_stats['last'][ch] = signal + self.rf_stats['missed'][ch] += missed + + def _update_rf_summaries(self): + # Update the summary stats, skip channels that do not matter. + # The pctgood is a measure of rf quality. + # For raw format, the values of pctgood will be calculated per active + # channel when the summaries are calculated, based upon the number of + # received good packets and number of missed packets. + + for ch in range(1, self.NUM_CHAN): # skip channel 0 (it is not used) + if self.rf_stats['cnt'][ch] > 0: + self.rf_stats['avg'][ch] = int(self.rf_stats['sum'][ch] / self.rf_stats['cnt'][ch]) + for ch in range(1, self.NUM_CHAN - 1): # no ch + if self.rf_stats['cnt'][ch] > 0: + self.rf_stats['pctgood'][ch] = \ + int(0.5 + 100.0 * self.rf_stats['cnt'][ch] / + (self.rf_stats['cnt'][ch] + self.rf_stats['missed'][ch])) + if -self.rf_stats['min'][ch] >= self.station.rfs and self.rf_stats['missed'][ch] > 0: + loginf('WARNING: rf_sensitivity (%s) might be too low for channel %s (%s signals missed)' + % (self.station.rfs, ch, self.rf_stats['missed'][ch])) + + def _report_rf_stats(self): + logdbg("RF summary: rf_sensitivity=%s (values in dB)" % + self.station.rfs) + logdbg("Station max min avg last count [missed] [good]") + for x in [('iss', self.station.channels['iss']), + ('wind', self.station.channels['anemometer']), + ('leaf_soil', self.station.channels['leaf_soil']), + ('temp_hum_1', self.station.channels['temp_hum_1']), + ('temp_hum_2', self.station.channels['temp_hum_2'])]: + if x[1] != 0: + self._report_channel(x[0], x[1]) + + def _report_channel(self, label, ch): + if self.rf_stats['pctgood'][ch] is None \ + or (-self.rf_stats['min'][ch] >= self.station.rfs and self.rf_stats['pctgood'][ch] < 85): + msg = "WARNING: rf_sensitivity might be too low for this channel" + else: + msg = "" + logdbg("%s %5d %5d %5d %5d %5d %7d %s %s" % + (label.ljust(15), + self.rf_stats['max'][ch], + self.rf_stats['min'][ch], + self.rf_stats['avg'][ch], + self.rf_stats['last'][ch], + self.rf_stats['cnt'][ch], + self.rf_stats['missed'][ch], + self.rf_stats['pctgood'][ch], + msg)) + + def new_archive_record(self, event): + self._update_rf_summaries() # calculate rf summaries + # Do not store first results after startup; the data are not complete + if not self.first_rf_stats: + event.record['rxCheckPercent'] = self.rf_stats['pctgood'][self.station.channels['iss']] + logdbg("data['rxCheckPercent']: %s" % event.record['rxCheckPercent']) + self.first_rf_stats = False + if DEBUG_RFS: + self._report_rf_stats() + self._init_rf_stats() # flush rf statistics + + +class Meteostick(object): + DEFAULT_PORT = '/dev/ttyUSB0' + DEFAULT_BAUDRATE = 115200 + DEFAULT_FREQUENCY = 'EU' + DEFAULT_RF_SENSITIVITY = 90 + MAX_RF_SENSITIVITY = 125 + + def __init__(self, **cfg): + self.port = cfg.get('port', self.DEFAULT_PORT) + loginf('using serial port %s' % self.port) + + self.baudrate = cfg.get('baudrate', self.DEFAULT_BAUDRATE) + loginf('using baudrate %s' % self.baudrate) + + freq = cfg.get('transceiver_frequency', self.DEFAULT_FREQUENCY) + if freq not in ['EU', 'US', 'AU']: + raise ValueError("invalid frequency %s" % freq) + self.frequency = freq + loginf('using frequency %s' % self.frequency) + + rfs = int(cfg.get('rf_sensitivity', self.DEFAULT_RF_SENSITIVITY)) + absrfs = abs(rfs) + if absrfs > self.MAX_RF_SENSITIVITY: + raise ValueError("invalid RF sensitivity %s" % rfs) + self.rfs = absrfs + self.rf_threshold = absrfs * 2 + loginf('using rf sensitivity %s (-%s dB)' % (rfs, absrfs)) + + channels = dict() + channels['iss'] = int(cfg.get('iss_channel', 1)) + channels['anemometer'] = int(cfg.get('anemometer_channel', 0)) + channels['leaf_soil'] = int(cfg.get('leaf_soil_channel', 0)) + channels['temp_hum_1'] = int(cfg.get('temp_hum_1_channel', 0)) + channels['temp_hum_2'] = int(cfg.get('temp_hum_2_channel', 0)) + if channels['anemometer'] == 0: + channels['wind_channel'] = channels['iss'] + else: + channels['wind_channel'] = channels['anemometer'] + self.channels = channels + loginf('using iss_channel %s' % channels['iss']) + loginf('using anemometer_channel %s' % channels['anemometer']) + loginf('using leaf_soil_channel %s' % channels['leaf_soil']) + loginf('using temp_hum_1_channel %s' % channels['temp_hum_1']) + loginf('using temp_hum_2_channel %s' % channels['temp_hum_2']) + + self.transmitters = Meteostick.ch_to_xmit( + channels['iss'], channels['anemometer'], channels['leaf_soil'], + channels['temp_hum_1'], channels['temp_hum_2']) + loginf('using transmitters %02x' % self.transmitters) + + self.timeout = 3 # seconds + self.serial_port = None + + @staticmethod + def ch_to_xmit(iss_channel, anemometer_channel, leaf_soil_channel, + temp_hum_1_channel, temp_hum_2_channel): + transmitters = 0 + transmitters += 1 << (iss_channel - 1) + if anemometer_channel != 0: + transmitters += 1 << (anemometer_channel - 1) + if leaf_soil_channel != 0: + transmitters += 1 << (leaf_soil_channel - 1) + if temp_hum_1_channel != 0: + transmitters += 1 << (temp_hum_1_channel - 1) + if temp_hum_2_channel != 0: + transmitters += 1 << (temp_hum_2_channel - 1) + return transmitters + + @staticmethod + def _check_crc(msg, chksum): + crc_result = crc16(msg) + if crc_result != chksum: + logerr('CRC result is 0x%04x, should be 0x%04x' % + (crc_result, chksum)) + raise ValueError("CRC error") + + def __enter__(self): + self.open() + return self + + def __exit__(self, _, value, traceback): + self.close() + + def open(self): + dbg_serial(1, "open serial port %s" % self.port) + self.serial_port = serial.Serial(self.port, self.baudrate, + timeout=self.timeout) + + def close(self): + if self.serial_port is not None: + dbg_serial(1, "close serial port %s" % self.port) + self.serial_port.close() + self.serial_port = None + + def get_readings(self): + buf = self.serial_port.readline().decode('utf-8') + if len(buf) > 0: + dbg_serial(2, "station said: %s" % + ' '.join(["%0.2X" % ord(c) for c in buf])) + return buf.strip() + + def get_readings_with_retry(self, max_tries=5, retry_wait=10): + for ntries in range(0, max_tries): + try: + return self.get_readings() + except serial.serialutil.SerialException as e: + loginf("Failed attempt %d of %d to get readings: %s" % + (ntries + 1, max_tries, e)) + time.sleep(retry_wait) + else: + msg = "Max retries (%d) exceeded for readings" % max_tries + logerr(msg) + raise weewx.RetriesExceeded(msg) + + def reset(self, max_wait=30): + """Reset the device, leaving it in a state that we can talk to it.""" + loginf("establish communication with the meteostick") + + # flush any previous data in the input buffer + self.serial_port.flushInput() + + # Send a reset command + self.serial_port.write(b'r\n') + # Wait until we see the ? character + start_ts = time.time() + ready = False + response = '' + while not ready: + time.sleep(0.1) + while self.serial_port.inWaiting() > 0: + c = self.serial_port.read(1).decode('utf-8') + if c == '?': + ready = True + elif c in string.printable: + response += c + if time.time() - start_ts > max_wait: + raise weewx.WakeupError("No 'ready' response from meteostick after %s seconds" % max_wait) + loginf("reset: %s" % response.split('\n')[0]) + dbg_serial(2, "full response to reset: %s" % response) + # Discard any serial input from the device + time.sleep(0.2) + self.serial_port.flushInput() + return response + + def configure(self): + """Configure the device to send data continuously.""" + loginf("configure meteostick to logger mode") + + # Show default settings (they might change with a new firmware version) + self.send_command('?') + + # Set RF threshold + self.send_command('x' + str(self.rf_threshold)) + + # Listen to configured transmitters + self.send_command('t' + str(self.transmitters)) + + # Filter transmissions from anything other than configured transmitters + self.send_command('f1') + + # Listen to configured repeaters + self.send_command('r1') # repeater 1 + + # Set device to produce 10-bytes raw data + command = 'o3' + self.send_command(command) + + # Set the frequency. Valid frequencies are US, EU and AU + command = 'm0' # default to US + if self.frequency == 'AU': + command = 'm2' + elif self.frequency == 'EU': + command = 'm1' + self.send_command(command) + + # From now on the device will produce lines with received data + + def send_command(self, cmd): + cmd2 = (cmd + "\r").encode('utf-8') + self.serial_port.write(cmd2) + time.sleep(0.2) + response = self.serial_port.read(self.serial_port.inWaiting()).decode('utf-8') + dbg_serial(1, "cmd: '%s': %s" % (cmd, response)) + self.serial_port.flushInput() + + @staticmethod + def get_parts(raw): + raw_str = str(raw) + dbg_parse(1, "readings: %s" % raw_str) + parts = raw.split(' ') + dbg_parse(3, "parts: %s (%s)" % (parts, len(parts))) + if len(parts) < 2: + raise ValueError("not enough parts in '%s'" % raw) + return parts + + def parse_readings(self, raw, rain_per_tip): + data = dict() + if not raw: + return data + if not all(c in string.printable for c in raw): + logerr("unprintable characters in readings: %s" % _fmt(raw)) + return data + try: + data = self.parse_raw(raw, + self.channels['iss'], + self.channels['anemometer'], + self.channels['leaf_soil'], + self.channels['temp_hum_1'], + self.channels['temp_hum_2'], + rain_per_tip) + except ValueError as e: + logerr("parse failed for '%s': %s" % (raw, e)) + return data + + @staticmethod + def parse_raw(raw, iss_ch, wind_ch, ls_ch, th1_ch, th2_ch, rain_per_tip): + data = dict() + parts = Meteostick.get_parts(raw) + n = len(parts) + if parts[0] == 'B': + # message example: + # B 29530 338141 366 101094 60 37 + data['channel'] = RAW_CHANNEL # rf_signal data will not be used + data['rf_signal'] = 0 # not available + data['rf_missed'] = 0 # not available + if n >= 6: + data['temp_in'] = float(parts[3]) / 10.0 # C + data['pressure'] = float(parts[4]) / 100.0 # hPa + if n > 7: + # only with custom receiver + data['humidity_in'] = float(parts[7]) + else: + logerr("B: not enough parts (%s) in '%s'" % (n, raw)) + elif parts[0] == 'I': + # raw Davis sensor message in 10 byte format incl header and + # additional info + # message example: + # ---- raw message ---- rfs ts_last + # I 102 51 0 DB FF 73 0 11 41 -65 5249944 202 + raw_msg = [0] * 10 + for i in range(0, 10): + raw_msg[i] = parts[i + 2] + pkt = bytearray([int(i, base=16) for i in raw_msg]) + + # perform crc-check + raw_msg_crc = [0] * 8 + if pkt[8] == 0xFF and pkt[9] == 0xFF: + # message received from davis equipment + # Calculate crc with bytes 0-7, result must be equal to 0 + chksum = 0 + for i in range(0, 8): + raw_msg_crc[i] = chr(int(parts[i + 2], 16)) + Meteostick._check_crc(raw_msg_crc, chksum) + else: + # message received via repeater + # Calculate crc with bytes 0-5 and 8-9, result must be equal + # to bytes 6-7 + chksum = (pkt[6] << 8) + pkt[7] + for i in range(0, 6): + raw_msg_crc[i] = chr(int(parts[i + 2], 16)) + for i in range(6, 8): + raw_msg_crc[i] = chr(int(parts[i + 4], 16)) + Meteostick._check_crc(raw_msg_crc, chksum) + + data['channel'] = (pkt[0] & 0x7) + 1 + battery_low = (pkt[0] >> 3) & 0x1 + data['rf_signal'] = int(parts[13]) + time_since_last = int(parts[14]) + # the cyclus time varies from 2.5 to 3 seconds for channels 1 to 8 + # simplifiy calculation with max cyclus time of 3.0 seconds + data['rf_missed'] = (time_since_last // 2500000) - 1 + if data['rf_missed'] > 0: + dbg_parse(3, "channel %s missed %s" % + (data['channel'], data['rf_missed'])) + + if data['channel'] == iss_ch or data['channel'] == wind_ch \ + or data['channel'] == th1_ch or data['channel'] == th2_ch: + if data['channel'] == iss_ch: + data['bat_iss'] = battery_low + elif data['channel'] == wind_ch: + data['bat_anemometer'] = battery_low + elif data['channel'] == th1_ch: + data['bat_th_1'] = battery_low + else: + data['bat_th_2'] = battery_low + # Each data packet of iss or anemometer contains wind info, + # but it is only valid when received from the channel with + # the anemometer connected + # message examples: + # I 101 51 6 B2 FF 73 0 76 61 -69 2624964 59 + # I 101 E0 0 0 4E 5 0 72 61 -68 2562440 68 (no sensor) + wind_speed_raw = pkt[1] + wind_dir_raw = pkt[2] + if not(wind_speed_raw == 0 and wind_dir_raw == 0): + """ The elder Vantage Pro and Pro2 stations measured + the wind direction with a potentiometer. This type has + a fairly big dead band around the North. The Vantage + Vue station uses a hall effect device to measure the + wind direction. This type has a much smaller dead band, + so there are two different formulas for calculating + the wind direction. To be able to select the right + formula the Vantage type must be known. + For now we use the traditional 'pro' formula for all + wind directions. + """ + dbg_parse(3, "wind_speed_raw=%03x wind_dir_raw=0x%03x" % + (wind_speed_raw, wind_dir_raw)) + + # Vantage Pro and Pro2 + if wind_dir_raw == 0: + wind_dir_pro = 5.0 + elif wind_dir_raw == 255: + wind_dir_pro = 355.0 + else: + wind_dir_pro = 9.0 + (wind_dir_raw - 1) * 342.0 / 253.0 + + # Vantage Vue + wind_dir_vue = wind_dir_raw * 1.40625 + 0.3 + + # wind error correction is by raw byte values + wind_speed_ec = round(Meteostick.calc_wind_speed_ec(wind_speed_raw, wind_dir_raw)) + + data['wind_speed_ec'] = wind_speed_ec + data['wind_speed_raw'] = wind_speed_raw + data['wind_dir'] = wind_dir_pro + data['wind_speed'] = wind_speed_ec * MPH_TO_MPS + dbg_parse(3, "WS=%s WD=%s WS_raw=%s WS_ec=%s WD_raw=%s WD_pro=%s WD_vue=%s" % + (data['wind_speed'], data['wind_dir'], + wind_speed_raw, wind_speed_ec, + wind_dir_raw if wind_dir_raw <= 180 else 360 - wind_dir_raw, + wind_dir_pro, wind_dir_vue)) + + # data from both iss sensors and extra sensors on + # Anemometer Transport Kit + message_type = (pkt[0] >> 4 & 0xF) + if message_type == 2: + # supercap voltage (Vue only) max: 0x3FF (1023) + # message example: + # I 103 20 4 C3 D4 C1 81 89 EE -77 2562520 -70 + """When the raw values are divided by 300 the maximum + voltage of the super capacitor will be about 2.8 V. This + is close to its maximum operating voltage of 2.7 V + """ + supercap_volt_raw = ((pkt[3] << 2) + (pkt[4] >> 6)) & 0x3FF + if supercap_volt_raw != 0x3FF: + data['supercap_volt'] = supercap_volt_raw / 300.0 + dbg_parse(3, "supercap_volt_raw=0x%03x value=%s" % + (supercap_volt_raw, data['supercap_volt'])) + elif message_type == 3: + # unknown message type + # message examples: + # TODO + # TODO (no sensor) + dbg_parse(1, "unknown message with type=0x03; " + "pkt[3]=0x%02x pkt[4]=0x%02x pkt[5]=0x%02x" + % (pkt[3], pkt[4], pkt[5])) + elif message_type == 4: + # uv + # message examples: + # I 103 40 00 00 12 45 00 B5 2A -78 2562444 -24 + # I 103 41 0 DE FF C3 0 A9 8D -65 2624976 -38 (no sensor) + uv_raw = ((pkt[3] << 2) + (pkt[4] >> 6)) & 0x3FF + if uv_raw != 0x3FF: + data['uv'] = uv_raw / 50.0 + dbg_parse(3, "uv_raw=%04x value=%s" % + (uv_raw, data['uv'])) + elif message_type == 5: + # rain rate + # message examples: + # I 104 50 0 0 FF 75 0 48 5B -77 2562452 140 (no rain) + # I 101 50 0 0 FE 75 0 7F 6B -66 2562464 68 (light_rain) + # I 100 50 0 0 1B 15 0 3F 80 -67 2562448 -95 (heavy_rain) + # I 102 51 0 DB FF 73 0 11 41 -65 5249944 202 (no sensor) + """ The published rain_rate formulas differ from each + other. For both light and heavy rain we like to know a + 'time between tips' in s. The rain_rate then would be: + 3600 [s/h] / time_between_tips [s] * 0.2 [mm] = xxx [mm/h] + """ + # typical time between tips: 64-1022 + time_between_tips_raw = ((pkt[4] & 0x30) << 4) + pkt[3] + dbg_parse(3, "time_between_tips_raw=%03x (%s)" % + (time_between_tips_raw, time_between_tips_raw)) + if data['channel'] == iss_ch: # rain sensor is present + rain_rate = None + if time_between_tips_raw == 0x3FF: + # no rain + rain_rate = 0 + dbg_parse(3, "no_rain=%s mm/h" % rain_rate) + elif pkt[4] & 0x40 == 0: + # heavy rain. typical value: + # 64/16 - 1020/16 = 4 - 63.8 (180.0 - 11.1 mm/h) + time_between_tips = time_between_tips_raw / 16.0 + rain_rate = 3600.0 / time_between_tips * rain_per_tip + dbg_parse(3, "heavy_rain=%s mm/h, time_between_tips=%s s" % + (rain_rate, time_between_tips)) + else: + # light rain. typical value: + # 64 - 1022 (11.1 - 0.8 mm/h) + time_between_tips = time_between_tips_raw + rain_rate = 3600.0 / time_between_tips * rain_per_tip + dbg_parse(3, "light_rain=%s mm/h, time_between_tips=%s s" % + (rain_rate, time_between_tips)) + data['rain_rate'] = rain_rate + elif message_type == 6: + # solar radiation + # message examples + # I 104 61 0 DB 0 43 0 F4 3B -66 2624972 121 + # I 104 60 0 0 FF C5 0 79 DA -77 2562444 137 (no sensor) + sr_raw = ((pkt[3] << 2) + (pkt[4] >> 6)) & 0x3FF + if sr_raw < 0x3FE: + data['solar_radiation'] = sr_raw * 1.757936 + dbg_parse(3, "solar_radiation_raw=0x%04x value=%s" + % (sr_raw, data['solar_radiation'])) + elif message_type == 7: + # solar cell output / solar power (Vue only) + # message example: + # I 102 70 1 F5 CE 43 86 58 E2 -77 2562532 173 + """When the raw values are divided by 300 the voltage comes + in the range of 2.8-3.3 V measured by the machine readable + format + """ + solar_power_raw = ((pkt[3] << 2) + (pkt[4] >> 6)) & 0x3FF + if solar_power_raw != 0x3FF: + data['solar_power'] = solar_power_raw / 300.0 + dbg_parse(3, "solar_power_raw=0x%03x solar_power=%s" + % (solar_power_raw, data['solar_power'])) + elif message_type == 8: + # outside temperature + # message examples: + # I 103 80 0 0 33 8D 0 25 11 -78 2562444 -25 (digital temp) + + # I 100 81 0 0 59 45 0 A3 E6 -89 2624956 -42 (analog temp) + # I 104 81 0 DB FF C3 0 AB F8 -66 2624980 125 (no digital sensor) + # I 101 81 5 C9 FF 83 0 73 AC FF FF -68 2624988 161 (no analog sensor) + temp_raw = (pkt[3] << 4) + (pkt[4] >> 4) # 12-bits temp value + if temp_raw != 0xFFC and temp_raw != 0xFF8: + if pkt[4] & 0x8: + # digital temp sensor - value is twos-complement + if pkt[3] & 0x80 != 0: + temp_f = -(temp_raw ^ 0xFFF) / 10.0 + else: + temp_f = temp_raw / 10.0 + temp_c = weewx.wxformulas.FtoC(temp_f) # C + dbg_parse(3, "digital temp_raw=0x%03x temp_f=%s temp_c=%s" + % (temp_raw, temp_f, temp_c)) + else: + # analog sensor (thermistor) + temp_raw = temp_raw // 4 # 10-bits temp value + temp_c = calculate_thermistor_temp(temp_raw) + dbg_parse(3, "thermistor temp_raw=0x%03x temp_c=%s" + % (temp_raw, temp_c)) + if data['channel'] == th1_ch: + data['temp_1'] = temp_c + elif data['channel'] == th2_ch: + data['temp_2'] = temp_c + elif data['channel'] == wind_ch: + data['temp_3'] = temp_c + else: + data['temperature'] = temp_c + elif message_type == 9: + # 10-min average wind gust + # message examples: + # I 102 91 0 DB 0 3 E 89 85 -66 2624972 204 + # I 102 90 0 0 0 5 0 31 51 -75 2562456 223 (no sensor) + gust_raw = pkt[3] # mph + gust_index_raw = pkt[5] >> 4 + if not(gust_raw == 0 and gust_index_raw == 0): + dbg_parse(3, "W10=%s gust_index_raw=%s" % + (gust_raw, gust_index_raw)) + # don't store the 10-min gust data because there is no + # field for it reserved in the standard wview schema + elif message_type == 0xA: + # outside humidity + # message examples: + # A0 00 00 C9 3D 00 2A 87 (digital sensor, variant a) + # A0 01 3A 80 3B 00 ED 0E (digital sensor, variant b) + # A0 01 41 7F 39 00 18 65 (digital sensor, variant c) + # A0 00 00 22 85 00 ED E3 (analog sensor) + # A1 00 DB 00 03 00 47 C7 (no sensor) + humidity_raw = ((pkt[4] >> 4) << 8) + pkt[3] + if humidity_raw != 0: + if pkt[4] & 0x08 == 0x8: + # digital sensor + humidity = humidity_raw / 10.0 + else: + # analog sensor (pkt[4] & 0x0f == 0x5) + humidity = humidity_raw * -0.301 + 710.23 + if data['channel'] == th1_ch: + data['humid_1'] = humidity + elif data['channel'] == th2_ch: + data['humid_2'] = humidity + elif data['channel'] == wind_ch: + loginf("Warning: humidity sensor of Anemometer Transmitter Kit not in sensor map: %s" % humidity) + else: + data['humidity'] = humidity + dbg_parse(3, "humidity_raw=0x%03x value=%s" % + (humidity_raw, humidity)) + elif message_type == 0xC: + # unknown message + # message example: + # I 101 C1 4 D0 0 1 0 E9 A4 -69 2624968 56 + # As we have seen after one day of received data + # pkt[3] and pkt[5] are always zero; + # pckt[4] has values 0-3 (ATK) or 5 (temp/hum) + dbg_parse(3, "unknown pkt[3]=0x%02x pkt[4]=0x%02x pkt[5]=0x%02x" % + (pkt[3], pkt[4], pkt[5])) + elif message_type == 0xE: + # rain + # message examples: + # I 103 E0 0 0 5 5 0 9F 3D -78 2562416 -28 + # I 101 E1 0 DB 80 3 0 16 8D -67 5249956 37 (no sensor) + rain_count_raw = pkt[3] + """We have seen rain counters wrap around at 127 and + others wrap around at 255. When we filter the highest + bit, both counter types will wrap at 127. + """ + if rain_count_raw != 0x80: + rain_count = rain_count_raw & 0x7F # skip high bit + data['rain_count'] = rain_count + dbg_parse(3, "rain_count_raw=0x%02x value=%s" % + (rain_count_raw, rain_count)) + else: + # unknown message type + logerr("unknown message type 0x%01x" % message_type) + + elif data['channel'] == ls_ch: + # leaf and soil station + data['bat_leaf_soil'] = battery_low + data_type = pkt[0] >> 4 + if data_type == 0xF: + data_subtype = pkt[1] & 0x3 + sensor_num = ((pkt[1] & 0xe0) >> 5) + 1 + temp_c = DEFAULT_SOIL_TEMP + temp_raw = ((pkt[3] << 2) + (pkt[5] >> 6)) & 0x3FF + potential_raw = ((pkt[2] << 2) + (pkt[4] >> 6)) & 0x3FF + + if data_subtype == 1: + # soil moisture + # message examples: + # I 102 F2 9 1A 55 C0 0 62 E6 -51 2687524 207 + # I 104 F2 29 FF FF C0 C0 F1 EC -52 2687408 124 (no sensor) + if pkt[3] != 0xFF: + # soil temperature + temp_c = calculate_thermistor_temp(temp_raw) + data['soil_temp_%s' % sensor_num] = temp_c + dbg_parse(3, "soil_temp_%s=%s 0x%03x" % + (sensor_num, temp_c, temp_raw)) + if pkt[2] != 0xFF: + # soil moisture potential + # Lookup soil moisture potential in SM_MAP + norm_fact = 0.009 # Normalize potential_raw + soil_moisture = lookup_potential( + "soil_moisture", norm_fact, + potential_raw, temp_c, SM_MAP) + data['soil_moisture_%s' % sensor_num] = soil_moisture + dbg_parse(3, "soil_moisture_%s=%s 0x%03x" % + (sensor_num, soil_moisture, potential_raw)) + elif data_subtype == 2: + # leaf wetness + # message examples: + # I 100 F2 A D4 55 80 0 90 6 -53 2687516 -121 + # I 101 F2 2A 0 FF 40 C0 4F 5 -52 2687404 43 (no sensor) + if pkt[3] != 0xFF: + # leaf temperature + temp_c = calculate_thermistor_temp(temp_raw) + data['leaf_temp_%s' % sensor_num] = temp_c + dbg_parse(3, "leaf_temp_%s=%s 0x%03x" % + (sensor_num, temp_c, temp_raw)) + if pkt[2] != 0: + # leaf wetness potential + # Lookup leaf wetness potential in LW_MAP + norm_fact = 0.0 # Do not normalize potential_raw + leaf_wetness = lookup_potential( + "leaf_wetness", norm_fact, + potential_raw, temp_c, LW_MAP) + data['leaf_wetness_%s' % sensor_num] = leaf_wetness + dbg_parse(3, "leaf_wetness_%s=%s 0x%03x" % + (sensor_num, leaf_wetness, potential_raw)) + else: + logerr("unknown subtype '%s' in '%s'" % (data_subtype, raw)) + + else: + logerr("unknown station with channel: %s, raw message: %s" % + (data['channel'], raw)) + elif parts[0] == '#': + loginf("%s" % raw) + else: + logerr("unknown sensor identifier '%s' in %s" % (parts[0], raw)) + return data + + # Normalize and interpolate raw wind values at raw angles + @staticmethod + def calc_wind_speed_ec(raw_mph, raw_angle): + + # some sanitization: no corrections needed under 3 and no values exist + # above 150 mph + if raw_mph < 3 or raw_mph > 150: + return raw_mph + + # Error correction values for + # [ 1..29 by 1, 30..150 by 5 raw mph ] + # x + # [ 1, 4, 8..124 by 4, 127, 128 raw degrees ] + # + # Extracted from a Davis Weather Envoy using a DIY transmitter to + # transmit raw values and logging LOOP packets. + # first row: raw angles; + # first column: raw speed; + # cells: values provided in response to raw data by the Envoy; + # [0][0] is filler + windtab = [ + [0, 1, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 92, 96, 100, 104, 108, 112, 116, 120, 124, 127, 128], + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0], + [4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0], + [5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0], + [6, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0], + [7, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 0, 0], + [8, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 0, 0], + [9, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 0, 0], + [10, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 0, 0], + [11, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 0, 0], + [12, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 0, 0], + [13, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 1, 0, 0], + [14, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 1, 0, 0], + [15, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 1, 0, 0], + [16, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 1, 0, 0], + [17, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 1, 0, 0], + [18, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 1, 0, 0], + [19, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 4, 4, 1, 0, 0], + [20, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 3, 4, 4, 2, 0, 0], + [21, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 3, 4, 4, 2, 0, 0], + [22, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 3, 4, 4, 2, 0, 0], + [23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 3, 4, 4, 2, 0, 0], + [24, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 2, 3, 4, 4, 2, 0, 0], + [25, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 3, 4, 4, 2, 0, 0], + [26, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 3, 5, 4, 2, 0, 0], + [27, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 3, 5, 5, 2, 0, 0], + [28, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 3, 5, 5, 2, 0, 0], + [29, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 3, 5, 5, 2, 0, 0], + [30, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 3, 5, 5, 2, 0, 0], + [35, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 4, 6, 5, 2, 0, -1], + [40, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 4, 6, 6, 2, 0, -1], + [45, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 4, 7, 6, 2, -1, -1], + [50, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 5, 7, 7, 2, -1, -2], + [55, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 5, 8, 7, 2, -1, -2], + [60, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 5, 8, 8, 2, -1, -2], + [65, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 5, 9, 8, 2, -2, -3], + [70, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 0, 2, 5, 9, 9, 2, -2, -3], + [75, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 0, 2, 6, 10, 9, 2, -2, -3], + [80, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 0, 2, 6, 10, 10, 2, -2, -3], + [85, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, 2, 7, 11, 11, 2, -3, -4], + [90, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 2, 7, 12, 11, 2, -3, -4], + [95, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 3, 2, 2, 2, 1, 1, 1, 1, 2, 7, 12, 12, 3, -3, -4], + [100, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 2, 2, 2, 1, 1, 1, 1, 2, 8, 13, 12, 3, -3, -4], + [105, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 2, 2, 2, 1, 1, 1, 2, 8, 13, 13, 3, -3, -4], + [110, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 2, 2, 2, 1, 1, 1, 2, 8, 14, 14, 3, -3, -5], + [115, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 2, 2, 2, 1, 1, 1, 2, 9, 15, 14, 3, -3, -5], + [120, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 2, 2, 2, 1, 1, 1, 3, 9, 15, 15, 3, -4, -5], + [125, 1, 1, 2, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 3, 2, 2, 1, 1, 1, 3, 10, 16, 16, 3, -4, -5], + [130, 1, 1, 2, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 1, 1, 3, 10, 17, 16, 3, -4, -6], + [135, 1, 2, 2, 1, 1, 0, 0, 0, -1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 4, 3, 3, 2, 2, 2, 1, 1, 3, 10, 17, 17, 4, -4, -6], + [140, 1, 2, 2, 1, 1, 0, 0, 0, -1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 4, 4, 3, 3, 2, 2, 2, 1, 1, 3, 11, 18, 17, 4, -4, -6], + [145, 2, 2, 2, 1, 1, 0, 0, 0, -1, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 4, 3, 3, 3, 2, 2, 1, 1, 3, 11, 19, 18, 4, -4, -6], + [150, 2, 2, 2, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 2, 3, 3, 4, 4, 4, 4, 4, 3, 3, 2, 2, 1, 1, 3, 12, 19, 19, 4, -4, -6] + ] + + # EC is symmetric between W/E (90/270°) - probably a wrong assumption, + # table needs to be redone for 0-360° + if raw_angle > 128: + raw_angle = 256 - raw_angle + + s0 = a0 = 1 + + while windtab[s0][0] < raw_mph: + s0 += 1 + while windtab[0][a0] < raw_angle: + a0 += 1 + + if windtab[s0][0] == raw_mph: + s1 = s0 + else: + if s0 > 1: + s0 -= 1 + s1 = len(windtab) - 1 if s0 == len(windtab) - 1 else s0 + 1 + + if windtab[0][a0] == raw_angle: + a1 = a0 + else: + if a0 > 1: + a0 -= 1 + a1 = len(windtab[0]) - 2 if a0 == len(windtab) - 1 else a0 + 1 + + if s0 == s1 and a0 == a1: + return raw_mph + windtab[s0][a0] + else: + return Meteostick.interpolate(windtab[0][a0], windtab[0][a1], + windtab[s0][0], windtab[s1][0], + windtab[s0][a0], windtab[s0][a1], + windtab[s1][a0], windtab[s1][a1], + raw_angle, raw_mph) + + # Simple bilinear interpolation + # + # a0 a1 <-- fixed raw angles + # x0---------x1 s0 + # | | + # | | + # | * <-|-- raw input angle, raw speed value (x, y) + # | | + # y0---------y1 s1 + # ^ + # \__ speed: measured raw / correction values + # + @staticmethod + def interpolate(rx0, rx1, + ry0, ry1, + x0, x1, + y0, y1, + x, y): + + dbg_parse(3, "rx0=%s, rx1=%s, ry0=%s, ry1=%s, x0=%s, x1=%s, y0=%s, y1=%s, x=%s, y=%s" % + (rx0, rx1, ry0, ry1, x0, x1, y0, y1, x, y)) + + if rx0 == rx1: + return y + x0 + (y - ry0) / float(ry1 - ry0) * (y1 - y0) + + if ry0 == ry1: + return y + y0 + (x - rx0) / float(rx1 - rx0) * (x1 - x0) + + dy0 = x0 + (y - ry0) / float(ry1 - ry0) * (y0 - x0) + dy1 = x1 + (y - ry0) / float(ry1 - ry0) * (y1 - x1) + + return y + dy0 + (x - rx0) / float(rx1 - rx0) * (dy1 - dy0) + + +class MeteostickConfEditor(weewx.drivers.AbstractConfEditor): + @property + def default_stanza(self): + return """ +[Meteostick] + # This section is for the Meteostick USB receiver. + + # The serial port to which the meteostick is attached, e.g., /dev/ttyS0 + port = /dev/ttyUSB0 + + # Radio frequency to use between USB transceiver and console: US, EU or AU + # US uses 915 MHz + # EU uses 868.3 MHz + # AU uses 915 MHz but has different frequency hopping values than US + transceiver_frequency = EU + + # A channel has value 0-8 where 0 indicates not present + # The channel of the Vantage Vue, Pro, or Pro2 ISS + iss_channel = 1 + # Additional channels apply only to Vantage Pro or Pro2 + anemometer_channel = 0 + leaf_soil_channel = 0 + temp_hum_1_channel = 0 + temp_hum_2_channel = 0 + + # Rain bucket type: 0 is 0.01 inch per tip, 1 is 0.2 mm per tip + rain_bucket_type = 1 + + # Print debug messages + # 0=no logging; 1=minimum logging; 2=normal logging; 3=detailed logging + debug_parse = 0 + debug_serial = 0 + debug_rain = 0 + debug_rf_sensitivity = 1 + + # The driver to use + driver = user.meteostick +""" + + def prompt_for_settings(self): + settings = dict() + print("Specify the serial port on which the meteostick is connected,") + print("for example /dev/ttyUSB0 or /dev/ttyS0") + settings['port'] = self._prompt('port', Meteostick.DEFAULT_PORT) + print("Specify the frequency between the station and the meteostick,") + print("one of US (915 MHz), EU (868.3 MHz), or AU (915 MHz)") + settings['transceiver_frequency'] = self._prompt('frequency', 'EU', ['US', 'EU', 'AU']) + print("Specify the type of the rain bucket,") + print("either 0 (0.01 inches per tip) or 1 (0.2 mm per tip)") + settings['rain_bucket_type'] = self._prompt('rain_bucket_type', MeteostickDriver.DEFAULT_RAIN_BUCKET_TYPE) + print("Specify the channel of the ISS (1-8)") + settings['iss_channel'] = self._prompt('iss_channel', 1) + print("Specify the channel of the Anemometer Transmitter Kit (0=none; 1-8)") + settings['anemometer_channel'] = self._prompt('anemometer_channel', 0) + print("Specify the channel of the Leaf & Soil station (0=none; 1-8)") + settings['leaf_soil_channel'] = self._prompt('leaf_soil_channel', 0) + print("Specify the channel of the first Temp/Humidity station (0=none; 1-8)") + settings['temp_hum_1_channel'] = self._prompt('temp_hum_1_channel', 0) + print("Specify the channel of the second Temp/Humidity station (0=none; 1-8)") + settings['temp_hum_2_channel'] = self._prompt('temp_hum_2_channel', 0) + return settings + + +class MeteostickConfigurator(weewx.drivers.AbstractConfigurator): + def add_options(self, parser): + super(MeteostickConfigurator, self).add_options(parser) + parser.add_option( + "--info", dest="info", action="store_true", + help="display meteostick configuration") + parser.add_option( + "--show-options", dest="opts", action="store_true", + help="display meteostick command options") + parser.add_option( + "--set-verbose", dest="verbose", metavar="X", type=int, + help="set verbose: 0=off, 1=on; default off") + parser.add_option( + "--set-debug", dest="debug", metavar="X", type=int, + help="set debug: 0=off, 1=on; default off") + # bug in meteostick: according to docs, 0=high, 1=low + parser.add_option( + "--set-ledmode", dest="led", metavar="X", type=int, + help="set led mode: 1=high 0=low; default low") + parser.add_option( + "--set-bandwidth", dest="bandwidth", metavar="X", type=int, + help="set bandwidth: " + "0=narrow: best for Davis sensors, " + "1=normal: for reading retransmitted packets, " + "2=wide: for Ambient stations); default narrow") + parser.add_option( + "--set-probe", dest="probe", metavar="X", type=int, + help="set probe: 0=off, 1=on; default off") + parser.add_option( + "--set-repeater", dest="repeater", metavar="X", type=int, + help="set repeater: 0-255; default 255") + parser.add_option( + "--set-channel", dest="channel", metavar="X", type=int, + help="set channel: 0-255; default 255") + parser.add_option( + "--set-format", dest="format", metavar="X", type=int, + help="set format: 0=raw, 1=machine, 2=human") + + def do_options(self, options, parser, config_dict, prompt): + driver = MeteostickDriver(None, config_dict) + info = driver.station.reset() + if options.info: + print(info) + cfg = { + 'v': options.verbose, + 'd': options.debug, + 'l': options.led, + 'b': options.bandwidth, + 'p': options.probe, + 'r': options.repeater, + 'c': options.channel, + 'o': options.format} + for opt in cfg: + if cfg[opt]: + cmd = opt + cfg[opt] + print("set station parameter %s" % cmd) + driver.station.send_command(cmd) + if options.opts: + driver.station.send_command('?') + print(driver.station.get()) + driver.closePort() + + +# define a main entry point for basic testing of the station without weewx +# engine and service overhead. invoke this as follows from the weewx root dir: +# +# PYTHONPATH=bin python bin/user/meteostick.py + +if __name__ == '__main__': + import optparse + + usage = """%prog [options] [--help]""" + + syslog.openlog('meteostick', syslog.LOG_PID | syslog.LOG_CONS) + syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_DEBUG)) + parser = optparse.OptionParser(usage=usage) + parser.add_option('--version', dest='version', action='store_true', + help='display driver version') + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the station is connected', + default=Meteostick.DEFAULT_PORT) + parser.add_option('--baud', dest='baud', metavar='BAUDRATE', + help='serial port baud rate', + default=Meteostick.DEFAULT_BAUDRATE) + parser.add_option('--freq', dest='freq', metavar='FREQUENCY', + help='comm frequency, either US (915MHz) or EU (868MHz)', + default=Meteostick.DEFAULT_FREQUENCY) + parser.add_option('--rfs', dest='rfs', metavar='RF_SENSITIVITY', + help='RF sensitivity in dB', + default=Meteostick.DEFAULT_RF_SENSITIVITY) + parser.add_option('--iss-channel', dest='c_iss', metavar='ISS_CHANNEL', + help='channel for ISS', default=1) + parser.add_option('--anemometer-channel', dest='c_a', + metavar='ANEMOMETER_CHANNEL', + help='channel for anemometer', default=0) + parser.add_option('--leaf-soil-channel', dest='c_ls', + metavar='LEAF_SOIL_CHANNEL', + help='channel for leaf-soil', default=0) + parser.add_option('--th1-channel', dest='c_th1', metavar='TH1_CHANNEL', + help='channel for T/H sensor 1', default=0) + parser.add_option('--th2-channel', dest='c_th2', metavar='TH2_CHANNEL', + help='channel for T/H sensor 2', default=0) + (opts, args) = parser.parse_args() + + if opts.version: + print("meteostick driver version %s" % DRIVER_VERSION) + exit(0) + + with Meteostick(port=opts.port, baudrate=opts.baud, + transceiver_frequency=opts.freq, + iss_channel=int(opts.c_iss), + anemometer_channel=int(opts.c_a), + leaf_soil_channel=int(opts.c_ls), + temp_hum_1_channel=int(opts.c_th1), + temp_hum_2_channel=int(opts.c_th2), + rf_sensitivity=int(opts.rfs)) as s: + while True: + print(time.time(), s.get_readings())