commit 307bcb7cdf9419f628d9599a4926dcde8a3c49ee Author: James Pattinson Date: Sun Oct 5 11:27:16 2025 +0100 first commit diff --git a/meteostick_reader.py b/meteostick_reader.py new file mode 100644 index 0000000..4b4353f --- /dev/null +++ b/meteostick_reader.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Standalone Meteostick reader script +Based on the meteostick.py weewx driver + +This script opens a Meteostick device, initializes it, and continuously +prints the raw messages received from Davis weather stations. +""" + +import time +import sys +import optparse +import math +import string + +try: + import serial +except ImportError: + print("Error: pyserial module is required. Install with: pip install pyserial") + sys.exit(1) + +# Simple CRC16 implementation for standalone use +def crc16(data): + """Simple CRC16 implementation""" + crc = 0 + for byte in data: + crc ^= ord(byte) << 8 + for _ in range(8): + if crc & 0x8000: + crc = (crc << 1) ^ 0x1021 + else: + crc <<= 1 + crc &= 0xFFFF + return crc + +# Simple logging functions +def logdbg(msg): + print(f"DEBUG: {msg}") + +def loginf(msg): + print(f"INFO: {msg}") + +def logerr(msg): + print(f"ERROR: {msg}") + +# Constants from meteostick.py +DEBUG_SERIAL = 0 +DEBUG_RAIN = 0 +DEBUG_PARSE = 0 +MPH_TO_MPS = 1609.34 / 3600.0 # meter/mile * hour/second +DEFAULT_SOIL_TEMP = 24 # C + +RAW = 0 # indices of table with raw values +POT = 1 # indices of table with potentials + +# Lookup tables +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)} + +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 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]) + +def calculate_thermistor_temp(temp_raw): + """Calculate thermistor temperature using Davis formulas.""" + # 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.""" + # 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 + +# Simplified Fahrenheit to Celsius conversion +def FtoC(temp_f): + """Convert Fahrenheit to Celsius""" + return (temp_f - 32.0) * 5.0 / 9.0 + +class Meteostick(object): + DEFAULT_PORT = '/dev/ttyUSB0' + DEFAULT_BAUDRATE = 115200 + DEFAULT_FREQUENCY = 'EU' + DEFAULT_RF_SENSITIVITY = 90 + MAX_RF_SENSITIVITY = 125 + RAW_CHANNEL = 0 # unused channel for the receiver stats in raw format + + 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 = self.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 Exception(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 Exception("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'] = Meteostick.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': + # ...existing code... + 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 + + # Wind data processing + wind_speed_raw = pkt[1] + wind_dir_raw = pkt[2] + if not(wind_speed_raw == 0 and wind_dir_raw == 0): + 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 + + data['wind_speed_raw'] = wind_speed_raw + data['wind_dir'] = wind_dir_pro + data['wind_speed'] = wind_speed_raw * MPH_TO_MPS + dbg_parse(3, "WS=%s WD=%s WS_raw=%s WD_raw=%s WD_pro=%s WD_vue=%s" % + (data['wind_speed'], data['wind_dir'], + wind_speed_raw, + 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) + + # Process different message types (simplified for reader) + if message_type == 8: + # outside temperature + 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 = FtoC(temp_f) + 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 == 0xA: + # outside humidity + 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 == 0xE: + # rain + rain_count_raw = pkt[3] + 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)) + + elif parts[0] == '#': + loginf("%s" % raw) + else: + logerr("unknown sensor identifier '%s' in %s" % (parts[0], raw)) + return data + +def main(): + usage = """%prog [options] [--help] + +Read and display messages from a Meteostick device.""" + + parser = optparse.OptionParser(usage=usage) + parser.add_option('--port', dest='port', metavar='PORT', + help='serial port to which the meteostick is connected', + default=Meteostick.DEFAULT_PORT) + parser.add_option('--baud', dest='baud', metavar='BAUDRATE', type=int, + help='serial port baud rate', + default=Meteostick.DEFAULT_BAUDRATE) + parser.add_option('--freq', dest='freq', metavar='FREQUENCY', + help='comm frequency: US (915MHz), EU (868MHz), or AU (915MHz)', + default=Meteostick.DEFAULT_FREQUENCY) + parser.add_option('--rfs', dest='rfs', metavar='RF_SENSITIVITY', type=int, + help='RF sensitivity (0-125, default 90)', + default=Meteostick.DEFAULT_RF_SENSITIVITY) + parser.add_option('--iss-channel', dest='c_iss', metavar='ISS_CHANNEL', type=int, + help='channel for ISS (1-8)', default=1) + parser.add_option('--anemometer-channel', dest='c_anem', metavar='ANEM_CHANNEL', type=int, + help='channel for anemometer (0=none, 1-8)', default=0) + parser.add_option('--leaf-soil-channel', dest='c_ls', metavar='LS_CHANNEL', type=int, + help='channel for leaf-soil station (0=none, 1-8)', default=0) + parser.add_option('--th1-channel', dest='c_th1', metavar='TH1_CHANNEL', type=int, + help='channel for T/H sensor 1 (0=none, 1-8)', default=0) + parser.add_option('--th2-channel', dest='c_th2', metavar='TH2_CHANNEL', type=int, + help='channel for T/H sensor 2 (0=none, 1-8)', default=0) + parser.add_option('--rain-bucket', dest='bucket', metavar='BUCKET_TYPE', type=int, + help='rain bucket type: 0=0.01in, 1=0.2mm (default 1)', default=1) + + (opts, args) = parser.parse_args() + + print("Meteostick Reader") + print("================") + print(f"Port: {opts.port}") + print(f"Baudrate: {opts.baud}") + print(f"Frequency: {opts.freq}") + print(f"RF Sensitivity: {opts.rfs}") + print(f"ISS Channel: {opts.c_iss}") + print(f"Anemometer Channel: {opts.c_anem}") + print(f"Leaf/Soil Channel: {opts.c_ls}") + print(f"T/H 1 Channel: {opts.c_th1}") + print(f"T/H 2 Channel: {opts.c_th2}") + print(f"Rain bucket type: {opts.bucket}") + print() + + # Calculate rain per tip based on bucket type + rain_per_tip = 0.254 if opts.bucket == 0 else 0.2 # mm + + try: + # Create and configure the Meteostick + station = Meteostick( + port=opts.port, + baudrate=opts.baud, + transceiver_frequency=opts.freq, + rf_sensitivity=opts.rfs, + iss_channel=opts.c_iss, + anemometer_channel=opts.c_anem, + leaf_soil_channel=opts.c_ls, + temp_hum_1_channel=opts.c_th1, + temp_hum_2_channel=opts.c_th2 + ) + + print("Opening connection...") + station.open() + + print("Resetting device...") + reset_info = station.reset() + print(f"Reset response: {reset_info.strip()}") + + print("Configuring device...") + station.configure() + + print("\nListening for messages (Press Ctrl+C to exit)...") + print("=" * 60) + + message_count = 0 + + while True: + try: + # Get raw reading + raw_reading = station.get_readings() + + if raw_reading: + message_count += 1 + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] Message #{message_count}: {raw_reading}") + + # Parse the reading for additional info + try: + parsed_data = station.parse_readings(raw_reading, rain_per_tip) + if parsed_data: + print(f" Parsed data: {parsed_data}") + except Exception as e: + print(f" Parse error: {e}") + + print() + else: + # No data received, small delay + time.sleep(0.1) + + except KeyboardInterrupt: + print("\nShutting down...") + break + except Exception as e: + print(f"Error reading data: {e}") + time.sleep(1) + + except Exception as e: + print(f"Error: {e}") + return 1 + + finally: + try: + station.close() + print("Connection closed.") + except: + pass + + print(f"Total messages received: {message_count}") + return 0 + +if __name__ == '__main__': + sys.exit(main())