diff --git a/.gitignore b/.gitignore index 89f9e71..802ef13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ .idea -.env.local .vscode +.env* dist node_modules + +sensors/bin +sensors/include +sensors/lib* +sensors/venv diff --git a/sensors/debug.py b/sensors/debug.py new file mode 100644 index 0000000..97d9eec --- /dev/null +++ b/sensors/debug.py @@ -0,0 +1,322 @@ +import time +import math +import board +import smbus2 +import serial +import pynmea2 +import bme680 +import adafruit_ltr390 +import threading +from datetime import datetime + +bus = smbus2.SMBus(1) +i2c = board.I2C() + +def try_read(fn): + try: + return fn() + except: + return None + +# ── BME680 (0x77) ──────────────────────────────────────────────────────────── + +GAS_REFERENCE = 250000 + +def gas_to_aqi(gas_ohms, humidity): + if gas_ohms is None: + return None + gas_score = min(gas_ohms / GAS_REFERENCE, 1.0) * 75 + hum_score = (25 - abs(humidity - 40) * 0.5) + aqi = min(max(round(gas_score + hum_score), 0), 100) + if aqi >= 80: label = "Excellent" + elif aqi >= 60: label = "Good" + elif aqi >= 40: label = "Fair" + elif aqi >= 20: label = "Poor" + else: label = "Very Poor" + return {'score': aqi, 'label': label} + +class BME680: + def __init__(self): + self._cache = None + self.sensor = bme680.BME680(bme680.I2C_ADDR_SECONDARY) + self.sensor.set_humidity_oversample(bme680.OS_2X) + self.sensor.set_pressure_oversample(bme680.OS_4X) + self.sensor.set_temperature_oversample(bme680.OS_8X) + self.sensor.set_filter(bme680.FILTER_SIZE_3) + self.sensor.set_gas_status(bme680.ENABLE_GAS_MEAS) + self.sensor.set_gas_heater_temperature(320) + self.sensor.set_gas_heater_duration(150) + self.sensor.select_gas_heater_profile(0) + + def read(self): + if self.sensor.get_sensor_data(): + dew_point = self.sensor.data.temperature - ((100 - self.sensor.data.humidity) / 5.0) + gas_ohms = round(self.sensor.data.gas_resistance, 0) if self.sensor.data.heat_stable else None + self._cache = { + 'temp_c': round(self.sensor.data.temperature, 2), + 'temp_f': round(self.sensor.data.temperature * 9/5 + 32, 2), + 'humidity': round(self.sensor.data.humidity, 2), + 'dew_point': round(dew_point, 2), + 'pressure': round(self.sensor.data.pressure, 2), + 'gas_ohms': gas_ohms, + 'aqi': gas_to_aqi(gas_ohms, self.sensor.data.humidity), + } + return self._cache + +# ── MPU6050 (0x68) ─────────────────────────────────────────────────────────── + +class MPU6050: + ADDR = 0x68 + CALIBRATION_SAMPLES = 200 + + def __init__(self): + try_read(lambda: bus.write_byte_data(self.ADDR, 0x6B, 0)) + time.sleep(0.1) + self.offsets = self._calibrate() + self._peak = None + self._lock = threading.Lock() + threading.Thread(target=self._sample_loop, daemon=True).start() + + def _calibrate(self): + print("Calibrating MPU6050, keep still...") + sums = [0] * 6 + for _ in range(self.CALIBRATION_SAMPLES): + d = try_read(lambda: bus.read_i2c_block_data(self.ADDR, 0x3B, 14)) + if d: + def s(v): return v - 65536 if v > 32767 else v + sums[0] += s((d[0] << 8) | d[1]) / 16384.0 + sums[1] += s((d[2] << 8) | d[3]) / 16384.0 + sums[2] += s((d[4] << 8) | d[5]) / 16384.0 + sums[3] += s((d[8] << 8) | d[9]) / 131.0 + sums[4] += s((d[10] << 8) | d[11]) / 131.0 + sums[5] += s((d[12] << 8) | d[13]) / 131.0 + time.sleep(0.005) + n = self.CALIBRATION_SAMPLES + return [sums[0]/n, sums[1]/n, sums[2]/n - 1.0, + sums[3]/n, sums[4]/n, sums[5]/n] + + def _sample_loop(self): + while True: + d = try_read(lambda: bus.read_i2c_block_data(self.ADDR, 0x3B, 14)) + if d: + def s(v): return v - 65536 if v > 32767 else v + o = self.offsets + ax = s((d[0] << 8) | d[1]) / 16384.0 - o[0] + ay = s((d[2] << 8) | d[3]) / 16384.0 - o[1] + az = s((d[4] << 8) | d[5]) / 16384.0 - o[2] + gx = s((d[8] << 8) | d[9]) / 131.0 - o[3] + gy = s((d[10] << 8) | d[11]) / 131.0 - o[4] + gz = s((d[12] << 8) | d[13]) / 131.0 - o[5] + # subtract gravity so idle magnitude ≈ 0 + mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2) + with self._lock: + if self._peak is None or mag > self._peak['magnitude']: + self._peak = { + 'ax': round(ax, 3), 'ay': round(ay, 3), 'az': round(az, 3), + 'gx': round(gx, 3), 'gy': round(gy, 3), 'gz': round(gz, 3), + 'magnitude': round(mag, 4), + } + time.sleep(0.01) + + def read(self): + with self._lock: + data = self._peak + self._peak = None # reset after each display read + return data + +# ── QMC5883L Compass (0x1E) ────────────────────────────────────────────────── + +def read_compass(): + addr = 0x1E + try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D)) + data = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6)) + if not data: + return None + def s(v): return v - 65536 if v > 32767 else v + x = s((data[1] << 8) | data[0]) + y = s((data[3] << 8) | data[2]) + z = s((data[5] << 8) | data[4]) + heading = math.degrees(math.atan2(y, x)) + if heading < 0: + heading += 360 + return {'x': x, 'y': y, 'z': z, 'heading': round(heading, 1)} + +# ── LTR390 (0x53) ──────────────────────────────────────────────────────────── + +UV_LABELS = [(0,'Low'),(3,'Moderate'),(6,'High'),(8,'Very High'),(11,'Extreme')] + +def uv_label(uvi): + label = 'Low' + for threshold, name in UV_LABELS: + if uvi >= threshold: + label = name + return label + +class LTR390: + def __init__(self): + self.sensor = try_read(lambda: adafruit_ltr390.LTR390(i2c)) + + def read(self): + if not self.sensor: + return None + return try_read(lambda: { + 'lux': round(self.sensor.lux, 2), + 'uvi': round(self.sensor.uvi, 2), + 'uv_label': uv_label(self.sensor.uvi), + 'solar_wm2': round(self.sensor.lux / 120, 2), + }) + +# ── TF-Luna (0x10) ─────────────────────────────────────────────────────────── + +def read_tfluna(): + data = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6)) + if not data: + return None + dist = data[0] | (data[1] << 8) + strength = data[2] | (data[3] << 8) + if strength < 100 or dist <= 0 or dist > 800: + return None + return {'distance_cm': dist, 'strength': strength} + +# ── AS3935 (0x03) ──────────────────────────────────────────────────────────── + +AS3935_ADDR = 0x03 +AS3935_NOISE_FLOOR = 7 # 0-7, raise if false positives persist +AS3935_WATCHDOG = 3 # 0-15 +AS3935_SPIKE_REJ = 15 # 0-15, filters EMI from TF-Luna + +def init_as3935(): + val = (AS3935_NOISE_FLOOR << 4) | AS3935_WATCHDOG + try_read(lambda: bus.write_byte_data(AS3935_ADDR, 0x01, val)) + try_read(lambda: bus.write_byte_data(AS3935_ADDR, 0x02, AS3935_SPIKE_REJ)) + try_read(lambda: bus.write_byte_data(AS3935_ADDR, 0x00, 0x24)) # indoor + +def read_as3935(): + data = try_read(lambda: bus.read_i2c_block_data(AS3935_ADDR, 0x00, 9)) + if not data: + return None + interrupt = data[3] & 0x0F + energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3] + try_read(lambda: bus.read_byte_data(AS3935_ADDR, 0x03)) # clear interrupt + if interrupt not in (0x04, 0x08) or energy < 1000: + return {'noise_floor': AS3935_NOISE_FLOOR, 'interrupt': 0, 'distance_km': 0, 'energy': 0} + return { + 'noise_floor': AS3935_NOISE_FLOOR, + 'interrupt': interrupt, + 'distance_km': data[6] & 0x3F, + 'energy': energy, + } + +# ── GPS (ttyS0) ────────────────────────────────────────────────────────────── + +gps_cache = {} + +def read_gps(): + try: + ser = serial.Serial('/dev/ttyS0', 9600, timeout=1) + line = ser.readline().decode('ascii', errors='replace').strip() + ser.close() + if 'GGA' in line: + msg = pynmea2.parse(line) + gps_cache.update({ + 'lat': msg.latitude, + 'lon': msg.longitude, + 'alt': msg.altitude, + 'satellites': msg.num_sats, + }) + elif 'VTG' in line: + msg = pynmea2.parse(line) + gps_cache['speed_kmh'] = msg.spd_over_grnd_kmph + elif 'RMC' in line: + msg = pynmea2.parse(line) + gps_cache['heading'] = msg.true_course + except: + pass + return gps_cache or None + +# ── Display ────────────────────────────────────────────────────────────────── + +def fmt(val, unit='', unavailable='—'): + return f"{val}{unit}" if val is not None else unavailable + +def section(title): + print(f"\n {title}") + print(f" {'─' * 40}") + +def print_dashboard(bme_data, mpu_data): + compass_data = read_compass() + ltr_data = ltr.read() + luna_data = read_tfluna() + as_data = read_as3935() + gps_data = read_gps() + + print('\033[2J\033[H', end='') + print(f"╔{'═'*50}╗") + print(f"║ 🌦 Weather Station — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ║") + print(f"╚{'═'*50}╝") + + section("🧭 BN-880 — GPS / Compass") + if gps_data: + print(f" Heading: {compass_data['heading']}°") + print(f" Lat/Lon: {fmt(gps_data.get('lat'))}°, {fmt(gps_data.get('lon'))}°") + print(f" Altitude: {fmt(gps_data.get('alt'), 'm')}") + print(f" Speed: {fmt(gps_data.get('speed_kmh'), ' km/h')}") + print(f" Satellites: {fmt(gps_data.get('satellites'))}") + else: + print(" ❌ Unavailable") + + section("🌡 BME680 — Temp / Humidity / Pressure / AQ") + if bme_data: + print(f" Temperature: {bme_data['temp_c']}°C ({bme_data['temp_f']}°F)") + print(f" Humidity: {bme_data['humidity']}%") + print(f" Dew Point: {bme_data['dew_point']}°C") + print(f" Pressure: {bme_data['pressure']} hPa") + print(f" Gas Res: {fmt(bme_data['gas_ohms'], ' Ω')}") + else: + print(" ❌ Unavailable") + + section("☀️ LTR390 — Light / UV") + if ltr_data: + print(f" Lux: {ltr_data['lux']}") + print(f" UV Index: {ltr_data['uvi']} — {ltr_data['uv_label']}") + print(f" Solar W/m²: {ltr_data['solar_wm2']}") + else: + print(" ❌ Unavailable") + + section("⚡ AS3935 — Lightning") + if as_data: + if as_data['interrupt'] == 0: + print(f" No event (noise floor: {as_data['noise_floor']}/7)") + else: + print(f" Distance: {as_data['distance_km']} km") + print(f" Energy: {as_data['energy']}") + else: + print(" ❌ Unavailable") + + section("📐 MPU6050 — Seismic / IMU") + if mpu_data: + print(f" Magnitude: {mpu_data['magnitude']}g") + else: + print(" ❌ Unavailable") + + section("📏 TF-Luna — Distance") + if luna_data: + print(f" Distance: {luna_data['distance_cm']} cm") + print(f" Strength: {luna_data['strength']}") + else: + print(" ❌ Unavailable") + + print(f"\n{'═'*52}\n") + +if __name__ == '__main__': + bme = BME680() + mpu = MPU6050() + ltr = LTR390() + init_as3935() + bme_data = None + mpu_data = None + while True: + bme_data = bme.read() or bme_data + mpu_data = mpu.read() + print_dashboard(bme_data, mpu_data) + time.sleep(1) diff --git a/sensors/main.py b/sensors/main.py new file mode 100644 index 0000000..f8003eb --- /dev/null +++ b/sensors/main.py @@ -0,0 +1,552 @@ +import time, math, board, smbus2, serial, pynmea2, bme680 +import adafruit_ltr390, threading, os +from datetime import datetime, timezone +from collections import deque +from influxdb_client import InfluxDBClient, Point +from influxdb_client.client.write_api import SYNCHRONOUS +from dotenv import load_dotenv +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +def cfg(): + load_dotenv(BASE_DIR / '.env', override=True) + load_dotenv(BASE_DIR / '.env.local', override=True) + return { + 'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'), + 'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''), + 'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'), + 'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'), + 'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'), + 'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')), + 'DEFAULT_LAT': float(os.getenv('DEFAULT_LAT', '0.0')), + 'DEFAULT_LON': float(os.getenv('DEFAULT_LON', '0.0')), + 'DEFAULT_ALT': float(os.getenv('DEFAULT_ALT', '0.0')), + 'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')), + 'PRESSURE_TREND_WINDOW':int(os.getenv('PRESSURE_TREND_WINDOW', '60')), + 'LUNA_CAL_SAMPLES': int( os.getenv('LUNA_CAL_SAMPLES', '20')), + 'LUNA_MAX_DIST_CM': int( os.getenv('LUNA_MAX_DIST_CM', '800')), + 'LUNA_MIN_DEPTH_CM': int( os.getenv('LUNA_MIN_DEPTH_CM', '2')), + 'AS3935_ADDR': int( os.getenv('AS3935_ADDR', '0x03'), 16), + 'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')), + 'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')), + 'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')), + 'SKIN_TYPE_2_MED': int( os.getenv('SKIN_TYPE_2_MED', '200')), + 'LOOP_INTERVAL': float(os.getenv('LOOP_INTERVAL', '1.0')), + 'FLOOD_TEMP_THRESHOLD':float(os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')), + 'SLUSH_TEMP_THRESHOLD':float(os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')), + 'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')), + 'SEISMIC_NOISE_FLOOR':float(os.getenv('SEISMIC_NOISE_FLOOR', '0.02')), + } + +# ── InfluxDB ────────────────────────────────────────────────────────────────── + +_influx_client = None +_influx_writer = None +_influx_url = None +_influx_token = None + +def get_writer(c): + global _influx_client, _influx_writer, _influx_url, _influx_token + if c['INFLUX_URL'] != _influx_url or c['INFLUX_TOKEN'] != _influx_token: + if _influx_client: _influx_client.close() + _influx_client = InfluxDBClient(url=c['INFLUX_URL'], token=c['INFLUX_TOKEN'], org=c['INFLUX_ORG']) + _influx_writer = _influx_client.write_api(write_options=SYNCHRONOUS) + _influx_url, _influx_token = c['INFLUX_URL'], c['INFLUX_TOKEN'] + return _influx_writer + +def write(c, measurement, fields, tags={}): + try: + p = Point(measurement).time(datetime.now(timezone.utc)) + for k, v in tags.items(): p = p.tag(k, v) + for k, v in fields.items(): + if v is not None: p = p.field(k, float(v) if isinstance(v, (int, float)) else v) + get_writer(c).write(bucket=c['INFLUX_BUCKET'], org=c['INFLUX_ORG'], record=p) + except Exception as e: + print(f"[InfluxDB] {measurement}: {e}") + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def try_init(fn, name): + try: + r = fn() + print(f" ✅ {name}") + return r + except Exception as e: + print(f" ❌ {name}: {e}") + return None + +def try_read(fn): + try: return fn() + except: return None + +def s16(v): return v - 65536 if v > 32767 else v + +try: bus = smbus2.SMBus(1) +except: bus = None; print("[I2C] SMBus init failed") + +try: i2c = board.I2C() +except: i2c = None; print("[I2C] board.I2C init failed") + +# ── BME680 ──────────────────────────────────────────────────────────────────── + +pressure_buffer = deque(maxlen=60) + +def init_bme(): + sensor = bme680.BME680(bme680.I2C_ADDR_SECONDARY) + sensor.set_humidity_oversample(bme680.OS_2X) + sensor.set_pressure_oversample(bme680.OS_4X) + sensor.set_temperature_oversample(bme680.OS_8X) + sensor.set_filter(bme680.FILTER_SIZE_3) + sensor.set_gas_status(bme680.ENABLE_GAS_MEAS) + sensor.set_gas_heater_temperature(320) + sensor.set_gas_heater_duration(150) + sensor.select_gas_heater_profile(0) + return sensor + +def heat_index(tc, rh): + tf = tc * 9/5 + 32 + if tf < 80: return round(tc, 2) + hi = (-42.379 + 2.04901523*tf + 10.14333127*rh + - 0.22475541*tf*rh - 0.00683783*tf**2 + - 0.05481717*rh**2 + 0.00122874*tf**2*rh + + 0.00085282*tf*rh**2 - 0.00000199*tf**2*rh**2) + return round((hi - 32) * 5/9, 2) + +def absolute_humidity(tc, rh): + return round((6.112 * math.exp((17.67*tc)/(tc+243.5)) * rh * 2.1674) / (273.15+tc), 3) + +def vpd(tc, rh): + es = 0.6108 * math.exp((17.27*tc)/(tc+237.3)) + return round(es - (rh/100)*es, 3) + +def gas_to_aqi(gas_ohms, humidity, gas_reference): + if gas_ohms is None: return None, None + score = min(max(round(min(gas_ohms/gas_reference,1.0)*75 + (25 - abs(humidity-40)*0.5)), 0), 100) + label = 'Very Poor' + if score >= 80: label = 'Excellent' + elif score >= 60: label = 'Good' + elif score >= 40: label = 'Fair' + elif score >= 20: label = 'Poor' + return score, label + +def sea_level_pressure(pressure_hpa, alt_m): + if alt_m is None: return None + return round(pressure_hpa / math.pow(1 - (alt_m / 44330.0), 5.255), 2) + +def frost_risk(temp_c, dew_point_c): + margin = temp_c - dew_point_c + if temp_c <= 0: return 'High' + if temp_c <= 3 and margin < 2: return 'Moderate' + if temp_c <= 5: return 'Low' + return 'None' + +def get_pressure_trend(window): + if pressure_buffer.maxlen != window: + pressure_buffer.__init__(window) + if len(pressure_buffer) < 2: return None, None + delta = pressure_buffer[-1] - pressure_buffer[0] + label = 'Rising' if delta > 1.0 else 'Falling' if delta < -1.0 else 'Stable' + return round(delta, 2), label + +def read_bme(c, sensor, alt_m=None): + null = { + 'env_temp_c': None, 'env_temp_f': None, 'env_humidity': None, + 'env_dew_point_c': None, 'env_pressure_hpa': None, 'env_pressure_slp': None, + 'env_pressure_rate': None, 'env_abs_humidity': None, 'env_vpd_kpa': None, + 'env_heat_index_c': None, 'env_gas_ohms': None, 'env_aqi_score': None, + } + if not sensor: + write(c, 'environment', null) + return None, None + try: + if not sensor.get_sensor_data(): return None, None + t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure + gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None + aqi_score, aqi_label = gas_to_aqi(gas, rh, c['GAS_REFERENCE']) + pressure_buffer.append(p) + p_rate, p_label = get_pressure_trend(c['PRESSURE_TREND_WINDOW']) + dp = round(t - (100-rh)/5.0, 2) + write(c, 'environment', { + 'env_temp_c': round(t, 2), + 'env_temp_f': round(t*9/5+32, 2), + 'env_humidity': round(rh, 2), + 'env_dew_point_c': dp, + 'env_pressure_hpa': round(p, 2), + 'env_pressure_slp': sea_level_pressure(p, alt_m), + 'env_pressure_rate': p_rate, + 'env_abs_humidity': absolute_humidity(t, rh), + 'env_vpd_kpa': vpd(t, rh), + 'env_heat_index_c': heat_index(t, rh), + 'env_gas_ohms': gas, + 'env_aqi_score': aqi_score, + }, tags={ + 'env_aqi_label': aqi_label or '', + 'env_pressure_trend': p_label or '', + 'env_frost_risk': frost_risk(t, dp), + }) + return t, rh + except Exception as e: + print(f"[BME680] {e}") + return None, None + +# ── MPU6050 ─────────────────────────────────────────────────────────────────── + +MPU_ADDR = 0x68 +_mpu_peak = None +_mpu_lock = threading.Lock() +_mpu_offsets = None + +def calibrate_mpu(samples=200): + if not bus: return None + print(" Calibrating MPU6050, keep still...") + sums = [0.0]*6 + n = 0 + for _ in range(samples): + d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14)) + if d: + sums[0] += s16((d[0] << 8)|d[1]) / 16384.0 + sums[1] += s16((d[2] << 8)|d[3]) / 16384.0 + sums[2] += s16((d[4] << 8)|d[5]) / 16384.0 + sums[3] += s16((d[8] << 8)|d[9]) / 131.0 + sums[4] += s16((d[10] << 8)|d[11]) / 131.0 + sums[5] += s16((d[12] << 8)|d[13]) / 131.0 + n += 1 + time.sleep(0.005) + if n == 0: return None + return [sums[0]/n, sums[1]/n, sums[2]/n-1.0, sums[3]/n, sums[4]/n, sums[5]/n] + +def mpu_loop(): + global _mpu_peak + while True: + if bus and _mpu_offsets: + d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14)) + if d: + o = _mpu_offsets + ax = s16((d[0] << 8)|d[1]) / 16384.0 - o[0] + ay = s16((d[2] << 8)|d[3]) / 16384.0 - o[1] + az = s16((d[4] << 8)|d[5]) / 16384.0 - o[2] + mag = math.sqrt(ax**2 + ay**2 + az**2) + with _mpu_lock: + if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']: + _mpu_peak = { + 'seismic_ax': round(ax, 3), + 'seismic_ay': round(ay, 3), + 'seismic_az': round(az, 3), + 'seismic_magnitude': round(mag, 4), + } + time.sleep(0.01) + +def flush_mpu(c): + global _mpu_peak + with _mpu_lock: + data, _mpu_peak = _mpu_peak, None + if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']: + write(c, 'seismic', data) + +# ── QMC5883L ────────────────────────────────────────────────────────────────── + +def read_compass(c): + null = {'compass_x': None, 'compass_y': None, 'compass_z': None, 'compass_heading': None} + if not bus: write(c, 'compass', null); return + try: + addr = 0x1E + try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D)) + d = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6)) + if not d: write(c, 'compass', null); return + x = s16((d[1] << 8)|d[0]) + y = s16((d[3] << 8)|d[2]) + z = s16((d[5] << 8)|d[4]) + heading = math.degrees(math.atan2(y, x)) + if heading < 0: heading += 360 + write(c, 'compass', { + 'compass_x': x, 'compass_y': y, 'compass_z': z, + 'compass_heading': round(heading, 1), + }) + except Exception as e: + print(f"[QMC5883L] {e}") + write(c, 'compass', null) + +# ── LTR390 ─────────────────────────────────────────────────────────────────── + +uv_dose_mj = 0.0 +uv_dose_date = datetime.now().date() +last_uv_time = time.time() +dli_lux_acc = 0.0 +dli_date = datetime.now().date() +last_lux_time = time.time() +daylight_start = None +DAYLIGHT_LUX_THRESHOLD = 50 + +def visibility_estimate(lux, humidity): + base_km = 50.0 + hum_factor = max(0, 1 - ((humidity - 40) / 60)) if humidity > 40 else 1.0 + lux_factor = min(lux / 10000, 1.0) + return round(base_km * hum_factor * lux_factor, 1) + +def read_ltr(c, sensor, solar_el, humidity=None): + global uv_dose_mj, uv_dose_date, last_uv_time + global dli_lux_acc, dli_date, last_lux_time, daylight_start + + null = { + 'light_lux': None, 'light_uvi': None, 'light_solar_wm2': None, + 'light_uv_dose_mj': None, 'light_burn_time_min': None, + 'light_cloud_pct': None, 'light_dli': None, + 'light_daylight_hours': None, 'light_visibility_km': None, + } + + if not sensor: write(c, 'light', null); return + + try: + lux = round(sensor.lux, 2) + uvi = round(sensor.uvi, 2) + except Exception as e: + print(f"[LTR390] {e}") + write(c, 'light', null) + return + + now = time.time() + today = datetime.now().date() + + if today != uv_dose_date: + uv_dose_mj, uv_dose_date = 0.0, today + if today != dli_date: + dli_lux_acc, dli_date, daylight_start = 0.0, today, None + + dt = now - last_uv_time + uv_dose_mj += uvi * 25 * dt + dli_lux_acc += lux * (now - last_lux_time) + last_uv_time = now + last_lux_time = now + + dli = round(dli_lux_acc / 54 / 1_000_000, 4) + + if lux >= DAYLIGHT_LUX_THRESHOLD and daylight_start is None: + daylight_start = now + daylight_hours = round((now - daylight_start) / 3600, 2) if daylight_start else 0.0 + + burn_min = round(c['SKIN_TYPE_2_MED'] / (uvi * 25 / 1000), 1) if uvi > 0 else None + + cloud_pct, cloud_label = None, None + if solar_el is not None and solar_el > 0: + cos_z = math.cos(math.radians(90 - solar_el)) + theoretical = 1361 * cos_z * 0.75 + if theoretical > 0: + ratio = min((lux / 120) / theoretical, 1.0) + cloud_pct = round((1 - ratio) * 100, 1) + cloud_label = ( + 'Clear' if cloud_pct < 20 else + 'Partly Cloudy' if cloud_pct < 50 else + 'Mostly Cloudy' if cloud_pct < 85 else + 'Overcast' + ) + + write(c, 'light', { + 'light_lux': lux, + 'light_uvi': uvi, + 'light_solar_wm2': round(lux / 120, 2), + 'light_uv_dose_mj': round(uv_dose_mj, 2), + 'light_burn_time_min': burn_min, + 'light_cloud_pct': cloud_pct, + 'light_dli': dli, + 'light_daylight_hours':daylight_hours, + 'light_visibility_km': visibility_estimate(lux, humidity) if humidity else None, + }, tags={'light_cloud_label': cloud_label or ''}) + +# ── TF-Luna ─────────────────────────────────────────────────────────────────── + +luna_baseline_cm = None + +def calibrate_luna(c): + global luna_baseline_cm + if not bus: print(" ❌ TF-Luna: no I2C bus"); return + print(" Calibrating TF-Luna...") + readings = [] + for _ in range(c['LUNA_CAL_SAMPLES']): + d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6)) + if d: + dist, strength = d[0]|(d[1]<<8), d[2]|(d[3]<<8) + if strength >= 100 and 0 < dist <= c['LUNA_MAX_DIST_CM']: + readings.append(dist) + time.sleep(0.1) + if readings: + luna_baseline_cm = round(sum(readings)/len(readings), 1) + print(f" ✅ TF-Luna baseline: {luna_baseline_cm} cm") + else: + print(" ❌ TF-Luna: calibration failed") + +def read_luna(c, temp_c): + null = {'ground_distance_cm': None, 'ground_lidar_strength': None} + if not bus: write(c, 'ground', null); return + try: + d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6)) + if not d: write(c, 'ground', null); return + dist = d[0]|(d[1]<<8) + strength = d[2]|(d[3]<<8) + if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']: + write(c, 'ground', null); return + + fields = {'ground_distance_cm': dist, 'ground_lidar_strength': strength} + + if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']: + t = temp_c + depth = round(luna_baseline_cm - dist, 1) + kind = ( + 'flood' if t is not None and t > c['FLOOD_TEMP_THRESHOLD'] else + 'slush' if t is not None and t > c['SLUSH_TEMP_THRESHOLD'] else + 'snow' if strength > c['SNOW_STRENGTH_MIN'] else 'ice' + ) + fields['ground_calibrated_baseline_cm'] = luna_baseline_cm + fields['ground_accumulation_depth_cm'] = depth + write(c, 'accumulation', fields, tags={'ground_accumulation_type': kind}) + else: + write(c, 'ground', fields) + except Exception as e: + print(f"[TF-Luna] {e}") + write(c, 'ground', null) + +# ── AS3935 ─────────────────────────────────────────────────────────────────── + +lightning_distances = deque(maxlen=10) +lightning_count = 0 +lightning_window_start = time.time() + +def init_as3935(c): + if not bus: return + try: + addr = c['AS3935_ADDR'] + val = (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG'] + bus.write_byte_data(addr, 0x01, val) + bus.write_byte_data(addr, 0x02, c['AS3935_SPIKE_REJ']) + bus.write_byte_data(addr, 0x00, 0x24) + print(" ✅ AS3935") + except Exception as e: + print(f" ❌ AS3935: {e}") + +def read_as3935(c): + global lightning_count, lightning_window_start + if not bus: return + try: + addr = c['AS3935_ADDR'] + data = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 9)) + if not data: return + interrupt = data[3] & 0x0F + energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3] + try_read(lambda: bus.read_byte_data(addr, 0x03)) + if interrupt not in (0x04, 0x08) or energy < 1000: return + dist = data[6] & 0x3F + lightning_distances.append(dist) + lightning_count += 1 + elapsed = time.time() - lightning_window_start + if elapsed >= 3600: + lightning_count = 1 + lightning_window_start = time.time() + elapsed = 1 + strike_rate = round(lightning_count / (elapsed / 3600), 1) + trend = 'Stationary' + if len(lightning_distances) >= 3: + avg_early = sum(list(lightning_distances)[:3]) / 3 + avg_late = sum(list(lightning_distances)[-3:]) / 3 + if avg_late < avg_early - 2: trend = 'Approaching' + elif avg_late > avg_early + 2: trend = 'Retreating' + write(c, 'lightning', { + 'lightning_distance_km': dist, + 'lightning_energy': energy, + 'lightning_detector_sensitivity': c['AS3935_NOISE_FLOOR'], + 'lightning_false_positive': 1 if interrupt == 0x04 else 0, + 'lightning_strikes_per_hour': strike_rate, + }, tags={'lightning_storm_trend': trend}) + except Exception as e: + print(f"[AS3935] {e}") + +# ── GPS ─────────────────────────────────────────────────────────────────────── + +gps_cache = {} + +def read_gps(c): + try: + ser = serial.Serial(c['GPS_PORT'], c['GPS_BAUD'], timeout=1) + line = ser.readline().decode('ascii', errors='replace').strip() + ser.close() + if 'GGA' in line: + msg = pynmea2.parse(line) + gps_cache.update({ + 'gps_lat': msg.latitude, + 'gps_lon': msg.longitude, + 'gps_alt_m': msg.altitude, + 'gps_satellites': int(msg.num_sats), + }) + elif 'VTG' in line: + msg = pynmea2.parse(line) + gps_cache['gps_speed_kmh'] = msg.spd_over_grnd_kmph + elif 'RMC' in line: + msg = pynmea2.parse(line) + gps_cache['gps_heading'] = msg.true_course + if gps_cache: + write(c, 'gps', {k: v for k, v in gps_cache.items() if v is not None}) + except Exception as e: + print(f"[GPS] {e}") + write(c, 'gps', { + 'gps_lat': None, 'gps_lon': None, 'gps_alt_m': None, + 'gps_satellites': None, 'gps_speed_kmh': None, 'gps_heading': None, + }) + return gps_cache + +def solar_elevation(lat, lon): + now = datetime.now(timezone.utc) + day = now.timetuple().tm_yday + decl = math.radians(23.45 * math.sin(math.radians((360/365)*(day-81)))) + hour_angle = math.radians(((now.hour + now.minute/60) - 12) * 15 + lon) + lat_r = math.radians(lat) + el = math.degrees(math.asin( + math.sin(lat_r)*math.sin(decl) + + math.cos(lat_r)*math.cos(decl)*math.cos(hour_angle) + )) + return round(el, 2) + +# ── Main ────────────────────────────────────────────────────────────────────── + +if __name__ == '__main__': + print("\n🌦 Weather Station — Initialising...\n") + c = cfg() + + bme_sensor = try_init(init_bme, 'BME680') + ltr_sensor = try_init(lambda: adafruit_ltr390.LTR390(i2c) if i2c else (_ for _ in ()).throw(Exception('no i2c')), 'LTR390') + + if bus: + try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0)) + time.sleep(0.1) + _mpu_offsets = try_init(calibrate_mpu, 'MPU6050') + else: + _mpu_offsets = None + print(" ❌ MPU6050: no I2C bus") + + threading.Thread(target=mpu_loop, daemon=True).start() + init_as3935(c) + calibrate_luna(c) + + print("\n🚀 Starting sensor loop...\n") + + last_temp = None + last_rh = None + + while True: + c = cfg() + gps = read_gps(c) + + lat = gps.get('gps_lat') or c['DEFAULT_LAT'] + lon = gps.get('gps_lon') or c['DEFAULT_LON'] + alt = gps.get('gps_alt_m') or c['DEFAULT_ALT'] + sol = solar_elevation(lat, lon) if lat and lon else None + + result = read_bme(c, bme_sensor, alt_m=alt) + if result and result[0] is not None: + last_temp, last_rh = result + + flush_mpu(c) + read_compass(c) + read_ltr(c, ltr_sensor, sol, humidity=last_rh) + read_luna(c, last_temp) + read_as3935(c) + + time.sleep(c['LOOP_INTERVAL']) diff --git a/sensors/pyvenv.cfg b/sensors/pyvenv.cfg new file mode 100644 index 0000000..7ef1a02 --- /dev/null +++ b/sensors/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.13.5 +executable = /usr/bin/python3.13 +command = /usr/bin/python3 -m venv /home/ztimson/Weather diff --git a/sensors/requirements.txt b/sensors/requirements.txt new file mode 100644 index 0000000..003167d --- /dev/null +++ b/sensors/requirements.txt @@ -0,0 +1,8 @@ +influxdb-client +python-dotenv +smbus2 +adafruit-circuitpython-ltr390 +bme680 +pyserial +pynmea2 +RPi.GPIO