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)
