added sensor files
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,11 @@
|
||||
.idea
|
||||
.env.local
|
||||
.vscode
|
||||
.env*
|
||||
|
||||
dist
|
||||
node_modules
|
||||
|
||||
sensors/bin
|
||||
sensors/include
|
||||
sensors/lib*
|
||||
sensors/venv
|
||||
|
||||
322
sensors/debug.py
Normal file
322
sensors/debug.py
Normal file
@@ -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)
|
||||
552
sensors/main.py
Normal file
552
sensors/main.py
Normal file
@@ -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'])
|
||||
5
sensors/pyvenv.cfg
Normal file
5
sensors/pyvenv.cfg
Normal file
@@ -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
|
||||
8
sensors/requirements.txt
Normal file
8
sensors/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
influxdb-client
|
||||
python-dotenv
|
||||
smbus2
|
||||
adafruit-circuitpython-ltr390
|
||||
bme680
|
||||
pyserial
|
||||
pynmea2
|
||||
RPi.GPIO
|
||||
Reference in New Issue
Block a user