from flask import Flask, jsonify, send_from_directory, request
import serial
import threading
import time
import json
import os
import logging
import math
import socket
import urllib.request
import urllib.error
import urllib.parse
import shutil
import sqlite3
import calendar
from datetime import datetime, timedelta
from collections import deque

app = Flask(__name__)
logging.getLogger("werkzeug").setLevel(logging.ERROR)

PORT = "COM3"
BAUDRATE = 2400

WIND_CALIBRATION_FACTOR = 1.6
TWO_MINUTES = 120
TEN_MINUTES = 600
RAIN_RATE_WINDOW_SECONDS = 900  # 15 minutes, Davis-style minimum measurable rate: 0.01 in / 15 min = 0.04 in/hr
RAIN_RATE_HOLD_SECONDS = 900
RAIN_TIP_SIZE_INCHES = 0.01

# PEET history must survive platform folder upgrades.  Older builds kept these
# files beside this script, which made every clean-version folder look like a
# brand-new station.  Store them in one Windows user-data location instead.
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PERSISTENT_ROOT = (
    os.environ.get("NWALS_DATA_DIR")
    or os.path.join(
        os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or SCRIPT_DIR,
        "NWALScannerPlatform",
        "data",
    )
)
PEET_DATA_DIR = os.path.join(PERSISTENT_ROOT, "peet")
os.makedirs(PEET_DATA_DIR, exist_ok=True)


def migrate_legacy_peet_file(filename):
    """Import the freshest valid legacy history file on first V014 startup."""
    target = os.path.join(PEET_DATA_DIR, filename)
    if os.path.exists(target):
        return target

    candidates = []
    platform_parent = os.path.dirname(SCRIPT_DIR)
    try:
        for entry in os.scandir(platform_parent):
            if not entry.is_dir() or not entry.name.lower().startswith("nwal-scanner-platform-v"):
                continue
            if os.path.normcase(os.path.abspath(entry.path)) == os.path.normcase(SCRIPT_DIR):
                continue
            candidate = os.path.join(entry.path, filename)
            if os.path.isfile(candidate):
                candidates.append(candidate)
    except OSError:
        pass

    candidates.sort(key=lambda path: os.path.getmtime(path), reverse=True)
    # A bundled/local file is only a last-resort seed. It must never outrank
    # the user's live history from an older sibling platform folder.
    if not candidates:
        local_legacy = os.path.join(SCRIPT_DIR, filename)
        if os.path.isfile(local_legacy):
            candidates.append(local_legacy)
    for candidate in candidates:
        try:
            with open(candidate, "r", encoding="utf-8") as source:
                json.load(source)
            shutil.copy2(candidate, target)
            print(f"Migrated PEET history: {candidate} -> {target}")
            break
        except (OSError, ValueError, json.JSONDecodeError):
            continue
    return target


DATA_FILE = migrate_legacy_peet_file("peet_daily_extremes.json")
GUST_FILE = migrate_legacy_peet_file("peet_10min_gust_samples.json")
RAIN_HISTORY_FILE = migrate_legacy_peet_file("peet_rain_24hr_history.json")
RAIN_24HR_SECONDS = 86400

# ===== Persistent 1-minute climate/history archive (V055) =====
# SQLite keeps WxPanel history outside the version folder so upgrades/restarts do not
# reset 24-hour change or longer-period records. One row per local clock minute.
WX_HISTORY_DB = os.path.join(PEET_DATA_DIR, "wxpanel_minute_history.sqlite3")
WX_RAIN_SEED_FILE = os.path.join(PEET_DATA_DIR, "wxpanel_rainfall_seed.json")
WX_CLIMATE_NORMALS_FILE = os.path.join(PEET_DATA_DIR, "wxpanel_climate_normals.json")


def _load_climate_normals():
    try:
        with open(WX_CLIMATE_NORMALS_FILE, "r", encoding="utf-8") as f:
            data=json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _save_climate_normals(data):
    os.makedirs(os.path.dirname(WX_CLIMATE_NORMALS_FILE), exist_ok=True)
    tmp=WX_CLIMATE_NORMALS_FILE + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    os.replace(tmp, WX_CLIMATE_NORMALS_FILE)


def _load_rainfall_seed():
    try:
        with open(WX_RAIN_SEED_FILE, "r", encoding="utf-8") as f:
            data=json.load(f)
        if not isinstance(data, dict):
            return {}
        return data
    except Exception:
        return {}

def _save_rainfall_seed(data):
    os.makedirs(os.path.dirname(WX_RAIN_SEED_FILE), exist_ok=True)
    tmp=WX_RAIN_SEED_FILE + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    os.replace(tmp, WX_RAIN_SEED_FILE)

def _seed_months_for_year(year):
    data=_load_rainfall_seed()
    raw=(data.get("years") or {}).get(str(year), {}) if isinstance(data, dict) else {}
    out={}
    for k,v in (raw or {}).items():
        try:
            m=int(k); n=float(v)
            if 1 <= m <= 12 and math.isfinite(n) and n >= 0:
                out[m]=n
        except Exception:
            pass
    return out
_history_lock = threading.Lock()
_last_history_minute = None
_history_stats_cache = {"items": [], "updatedEpoch": 0, "historyStartEpoch": None}

def _history_conn():
    conn = sqlite3.connect(WX_HISTORY_DB, timeout=10)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("""CREATE TABLE IF NOT EXISTS minute_obs (
        ts INTEGER PRIMARY KEY, temp REAL, dewpoint REAL, humidity REAL, pressure REAL, wind REAL,
        heat_index REAL, wind_chill REAL, rain_rate REAL, rain_today REAL,
        wind_avg_2min_max REAL, gust_10min_max REAL
    )""")
    # Existing V055-V057 databases need the new rainfall-total column added in place.
    cols = {row[1] for row in conn.execute("PRAGMA table_info(minute_obs)")}
    if "rain_today" not in cols:
        conn.execute("ALTER TABLE minute_obs ADD COLUMN rain_today REAL")
    if "humidity" not in cols:
        conn.execute("ALTER TABLE minute_obs ADD COLUMN humidity REAL")
    if "wind_avg_2min_max" not in cols:
        conn.execute("ALTER TABLE minute_obs ADD COLUMN wind_avg_2min_max REAL")
    if "gust_10min_max" not in cols:
        conn.execute("ALTER TABLE minute_obs ADD COLUMN gust_10min_max REAL")
    conn.commit()
    conn.execute("CREATE INDEX IF NOT EXISTS idx_minute_obs_ts ON minute_obs(ts)")
    return conn

def _valid_num(value):
    try:
        n = float(value)
        return n if math.isfinite(n) else None
    except (TypeError, ValueError):
        return None

def record_minute_history(snapshot):
    global _last_history_minute
    now = int(time.time())
    minute_ts = now - (now % 60)
    if _last_history_minute == minute_ts:
        return
    temp = _valid_num(snapshot.get("OutdoorTemp"))
    if temp is None:
        return
    dew = _valid_num(snapshot.get("OutdoorDewpoint"))
    pressure = _valid_num(snapshot.get("Pressure"))
    humidity = _valid_num(snapshot.get("OutdoorHum"))
    rain_rate = _valid_num(snapshot.get("RainRate"))
    rain_today = _valid_num(snapshot.get("RainToday"))
    # V062: archive two independent wind statistics.  These are the highest
    # rolling 2-minute average and highest 10-minute gust observed inside the
    # 60-second archive bucket, not a single sample taken at minute end.
    wind_avg_max = _valid_num(snapshot.get("MinuteMax2MinAverageWind"))
    gust_max = _valid_num(snapshot.get("MinuteMax10MinGust"))
    if wind_avg_max is None:
        wind_avg_max = _valid_num(snapshot.get("WindAverage"))
    if gust_max is None:
        gust_max = _valid_num(snapshot.get("Recentmaxgust"))
    wind_legacy = gust_max
    hi = calc_heat_index_f(temp, humidity) if humidity is not None and temp >= 80 else None
    wc_wind = wind_avg_max if wind_avg_max is not None else _valid_num(snapshot.get("WindLatest"))
    wc = calc_wind_chill_f(temp, wc_wind) if wc_wind is not None and temp <= 50 and wc_wind >= 3 else None
    try:
        with _history_lock:
            conn = _history_conn()
            conn.execute("""INSERT OR REPLACE INTO minute_obs(
                ts,temp,dewpoint,humidity,pressure,wind,heat_index,wind_chill,rain_rate,rain_today,
                wind_avg_2min_max,gust_10min_max
            ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
                (minute_ts,temp,dew,humidity,pressure,wind_legacy,hi,wc,rain_rate,rain_today,wind_avg_max,gust_max))
            conn.commit(); conn.close()
        _last_history_minute = minute_ts
    except Exception as e:
        print(f"WX HISTORY write error: {e}")

def _period_bounds(now_dt, period):
    if period == "week":
        start = (now_dt - timedelta(days=now_dt.weekday())).replace(hour=0,minute=0,second=0,microsecond=0)
    elif period == "month":
        start = now_dt.replace(day=1,hour=0,minute=0,second=0,microsecond=0)
    elif period == "year":
        start = now_dt.replace(month=1,day=1,hour=0,minute=0,second=0,microsecond=0)
    else:
        start = None
    return int(start.timestamp()) if start else None, int(now_dt.timestamp())

def _range_stats(conn, start_ts, end_ts):
    where = "ts<=?" if start_ts is None else "ts>=? AND ts<=?"
    params = (end_ts,) if start_ts is None else (start_ts,end_ts)
    row = conn.execute(f"""SELECT MIN(temp),MAX(temp),MIN(dewpoint),MAX(dewpoint),
        MIN(pressure),MAX(pressure),MAX(wind),MAX(heat_index),MIN(wind_chill),MAX(rain_rate)
        FROM minute_obs WHERE {where}""", params).fetchone()
    return row

def _day_temp_stats(conn, day):
    start = datetime(day.year,day.month,day.day)
    end = start + timedelta(days=1)
    row = conn.execute("SELECT MIN(temp),MAX(temp) FROM minute_obs WHERE ts>=? AND ts<?",
                       (int(start.timestamp()),int(end.timestamp()))).fetchone()
    return row if row and row[0] is not None else None

def _period_rainfall(conn, start_ts, end_ts):
    """Return accumulated rainfall for a calendar period from RainToday snapshots.

    RainToday is a running daily total and resets at midnight.  Summing each local
    day's maximum preserves the actual daily accumulation without multiplying the
    total by every one-minute archive row.
    """
    if start_ts is None:
        return None
    rows = conn.execute("""
        SELECT date(ts, 'unixepoch', 'localtime') AS local_day, MAX(rain_today)
        FROM minute_obs
        WHERE ts>=? AND ts<=? AND rain_today IS NOT NULL AND rain_today>=0
        GROUP BY local_day
    """, (start_ts, end_ts)).fetchall()
    vals=[float(r[1]) for r in rows if r[1] is not None]
    return sum(vals) if vals else None

def _year_rainfall_with_seed(conn, year, end_ts):
    """Combine optional manually seeded monthly totals with live archived rainfall.

    A seeded month replaces archive-derived rainfall for that calendar month,
    preventing double counting when a client imports completed months during
    station onboarding. Unseeded months continue to use live minute history.
    """
    seeds=_seed_months_for_year(year)
    start=datetime(year,1,1)
    rows=conn.execute("""
        SELECT CAST(strftime('%m', ts, 'unixepoch', 'localtime') AS INTEGER) AS local_month,
               date(ts, 'unixepoch', 'localtime') AS local_day, MAX(rain_today)
        FROM minute_obs
        WHERE ts>=? AND ts<=? AND rain_today IS NOT NULL AND rain_today>=0
        GROUP BY local_month, local_day
    """, (int(start.timestamp()), int(end_ts))).fetchall()
    archive_by_month={}
    for month, _day, val in rows:
        if val is not None:
            archive_by_month[int(month)]=archive_by_month.get(int(month),0.0)+float(val)
    months=set(archive_by_month) | set(seeds)
    if not months:
        return None
    return sum(seeds.get(m, archive_by_month.get(m,0.0)) for m in months)

def _merge_today_extremes(period_stats):
    """Merge parser-known daily extrema into archive stats for periods containing today.

    The 1-minute archive can miss brief gusts and, when newly created, does not include
    observations from earlier in the current day. The live daily-extremes engine does.
    """
    if not period_stats:
        return period_stats
    vals=list(period_stats)
    # tuple order: tmin,tmax,dmin,dmax,pmin,pmax,wmax,himax,wcmin,rrmax
    mappings = [
        (0, "LowTempToday", min), (1, "HighTempToday", max),
        (2, "LowDewpointToday", min), (3, "HighDewpointToday", max),
        (4, "LowPressToday", min), (5, "HighPressToday", max),
        (6, "HighGustToday", max), (9, "HighRainRateToday", max),
    ]
    for idx,key,fn in mappings:
        live=_valid_num(daily_extremes.get(key))
        if live is None:
            continue
        cur=_valid_num(vals[idx])
        vals[idx]=live if cur is None else fn(cur,live)
    return tuple(vals)

def _display_day(ts, now_dt):
    d=datetime.fromtimestamp(int(ts))
    if d.date()==now_dt.date():
        return "Today"
    if d.date()==(now_dt-timedelta(days=1)).date():
        return "Yesterday"
    return d.strftime("%a %b %-d") if os.name != "nt" else d.strftime("%a %b %#d")

def _derived_dew_extrema(conn, start_ts, end_ts):
    """Recompute dew-point extrema from archived FINAL temp + humidity.

    V064 made live dew point derived from the final selected/calibrated temperature
    and humidity. Older minute rows can contain a stale pre-V064 dewpoint column, so
    longer-period extrema must not trust that stored derived value. Recomputing here
    also keeps month/year/all-time dew statistics consistent with the values WxPanel
    actually displayed.
    """
    where = "ts<=?" if start_ts is None else "ts>=? AND ts<=?"
    params = (end_ts,) if start_ts is None else (start_ts, end_ts)
    rows = conn.execute(f"SELECT temp, humidity FROM minute_obs WHERE {where} AND temp IS NOT NULL AND humidity IS NOT NULL", params).fetchall()
    vals = []
    for temp, humidity in rows:
        t = _valid_num(temp); h = _valid_num(humidity)
        if t is None or h is None or not (0 < h <= 100):
            continue
        d = calc_dewpoint_f(t, h)
        if d is not None and math.isfinite(d):
            vals.append(d)
    return (min(vals), max(vals)) if vals else (None, None)

def _period_extremes(conn, start_ts, end_ts, include_today=True):
    where = "ts<=?" if start_ts is None else "ts>=? AND ts<=?"
    params = (end_ts,) if start_ts is None else (start_ts,end_ts)
    row=conn.execute(f"""SELECT
        MIN(temp), MAX(temp),
        MIN(CASE WHEN wind_chill IS NOT NULL AND wind_chill < temp THEN wind_chill ELSE temp END),
        MAX(CASE WHEN heat_index IS NOT NULL AND heat_index > temp THEN heat_index ELSE temp END),
        MIN(dewpoint), MAX(dewpoint), MIN(humidity), MAX(humidity),
        MIN(pressure), MAX(pressure), MAX(rain_rate),
        MAX(COALESCE(gust_10min_max,wind)), MAX(wind_avg_2min_max)
        FROM minute_obs WHERE {where}""", params).fetchone()
    if not row or row[0] is None:
        return None
    vals=list(row)
    if include_today:
        live_map=[
            (0,"LowTempToday",min),(1,"HighTempToday",max),
            (2,"LowFeelsLikeToday",min),(3,"HighFeelsLikeToday",max),
            (4,"LowDewpointToday",min),(5,"HighDewpointToday",max),
            (6,"LowHumToday",min),(7,"HighHumToday",max),
            (8,"LowPressToday",min),(9,"HighPressToday",max),
            (10,"HighRainRateToday",max),(11,"HighGustToday",max),(12,"HighAvgWindToday",max),
        ]
        for i,key,fn in live_map:
            v=_valid_num(daily_extremes.get(key))
            if v is None: continue
            cur=_valid_num(vals[i])
            vals[i]=v if cur is None else fn(cur,v)
    keys=["tempLow","tempHigh","feelsLow","feelsHigh","dewLow","dewHigh","humLow","humHigh","pressureLow","pressureHigh","rainRateHigh","windHigh","windAvgHigh"]
    out=dict(zip(keys,vals))
    # V065: stored dewpoint values from pre-V064 builds may have been derived from
    # a different source than the final calibrated temp/RH. Recompute period dew
    # extrema from the archived final temp + humidity instead.
    dew_low, dew_high = _derived_dew_extrema(conn, start_ts, end_ts)
    if dew_low is not None:
        out["dewLow"] = dew_low
    if dew_high is not None:
        out["dewHigh"] = dew_high
    out["gustHigh"]=out.get("windHigh")
    return out


def _daily_extreme_payload():
    return {
        "tempLow": _valid_num(daily_extremes.get("LowTempToday")),
        "tempHigh": _valid_num(daily_extremes.get("HighTempToday")),
        "feelsLow": _valid_num(daily_extremes.get("LowFeelsLikeToday")),
        "feelsHigh": _valid_num(daily_extremes.get("HighFeelsLikeToday")),
        "dewLow": _valid_num(daily_extremes.get("LowDewpointToday")),
        "dewHigh": _valid_num(daily_extremes.get("HighDewpointToday")),
        "humLow": _valid_num(daily_extremes.get("LowHumToday")),
        "humHigh": _valid_num(daily_extremes.get("HighHumToday")),
        "pressureLow": _valid_num(daily_extremes.get("LowPressToday")),
        "pressureHigh": _valid_num(daily_extremes.get("HighPressToday")),
        "rainRateHigh": _valid_num(daily_extremes.get("HighRainRateToday")),
        "windHigh": _valid_num(daily_extremes.get("HighGustToday")),
        "gustHigh": _valid_num(daily_extremes.get("HighGustToday")),
        "windAvgHigh": _valid_num(daily_extremes.get("HighAvgWindToday")),
    }


def _daily_observed_stats(conn, start_day, end_day, now_dt):
    """Return daily observed high/low rows for climate-average calculations."""
    start_ts=int(datetime(start_day.year,start_day.month,start_day.day).timestamp())
    end_dt=datetime(end_day.year,end_day.month,end_day.day)+timedelta(days=1)
    rows=conn.execute("""
        SELECT date(ts,'unixepoch','localtime') d, MIN(temp), MAX(temp)
        FROM minute_obs WHERE ts>=? AND ts<? GROUP BY d ORDER BY d
    """,(start_ts,int(end_dt.timestamp()))).fetchall()
    out={r[0]: [r[1],r[2]] for r in rows if r[1] is not None and r[2] is not None}
    today_key=now_dt.strftime("%Y-%m-%d")
    if start_day <= now_dt.date() <= end_day:
        lo=_valid_num(daily_extremes.get("LowTempToday")); hi=_valid_num(daily_extremes.get("HighTempToday"))
        if lo is not None and hi is not None:
            out[today_key]=[lo,hi]
    return out


def _climate_insights(conn, now_dt, periods):
    profile=_load_climate_normals()
    daily=profile.get("daily") or {}
    if not isinstance(daily,dict):
        return []
    items=[]
    today=daily.get(now_dt.strftime("%m-%d")) or {}
    th=_valid_num(today.get("high")); tl=_valid_num(today.get("low")); tm=_valid_num(today.get("mean"))
    actual_hi=_valid_num(daily_extremes.get("HighTempToday")); actual_lo=_valid_num(daily_extremes.get("LowTempToday"))
    if actual_hi is not None and th is not None:
        dep=round(actual_hi-th); items.append({"label":"Today High vs Normal","value":f"{dep:+d}°" if dep else "0°"})
    if actual_lo is not None and tl is not None:
        dep=round(actual_lo-tl); items.append({"label":"Today Low vs Normal","value":f"{dep:+d}°" if dep else "0°"})

    # Month-to-date: require normals for every calendar day through today so a
    # partial climate profile cannot create misleading departures.
    month_start=now_dt.replace(day=1).date()
    days=[month_start+timedelta(days=i) for i in range((now_dt.date()-month_start).days+1)]
    normal_rows=[]
    for d in days:
        r=daily.get(d.strftime("%m-%d")) or {}
        vals=(_valid_num(r.get("high")),_valid_num(r.get("low")),_valid_num(r.get("mean")),_valid_num(r.get("precip")))
        if any(v is None for v in vals):
            normal_rows=[]; break
        normal_rows.append(vals)
    if normal_rows:
        obs=_daily_observed_stats(conn,month_start,now_dt.date(),now_dt)
        if len(obs) == len(days):
            highs=[v[1] for v in obs.values()]; lows=[v[0] for v in obs.values()]
            actual_avg_hi=sum(highs)/len(highs); actual_avg_lo=sum(lows)/len(lows)
            actual_avg_mean=sum((h+l)/2 for h,l in zip(highs,lows))/len(highs)
            norm_hi=sum(r[0] for r in normal_rows)/len(normal_rows)
            norm_lo=sum(r[1] for r in normal_rows)/len(normal_rows)
            norm_mean=sum(r[2] for r in normal_rows)/len(normal_rows)
            for label,actual,norm in (
                ("MTD Avg High Departure",actual_avg_hi,norm_hi),
                ("MTD Avg Low Departure",actual_avg_lo,norm_lo),
                ("MTD Avg Temp Departure",actual_avg_mean,norm_mean),
            ):
                dep=round(actual-norm); items.append({"label":label,"value":f"{dep:+d}°" if dep else "0°"})
        month_rain=(periods.get("month") or {}).get("rainfall")
        normal_rain=sum(r[3] for r in normal_rows)
        first_ts=conn.execute("SELECT MIN(ts) FROM minute_obs").fetchone()[0]
        first_day=datetime.fromtimestamp(first_ts).date() if first_ts else None
        # Do not claim an MTD rainfall departure when this station archive began
        # after the first day of the current month.  A partial month is not a fair
        # comparison to a full month-to-date normal.
        if month_rain is not None and first_day is not None and first_day <= month_start:
            dep=float(month_rain)-normal_rain
            items.append({"label":"MTD Rain vs Normal (in)","value":f"{dep:+.2f}" if abs(dep)>=0.005 else "0.00"})
    return items

def _rolling_rainfall(conn, start_ts, end_ts):
    rows=conn.execute("""
        SELECT date(ts, 'unixepoch', 'localtime') AS local_day, MAX(rain_today)
        FROM minute_obs WHERE ts>=? AND ts<=? AND rain_today IS NOT NULL AND rain_today>=0
        GROUP BY local_day
    """,(start_ts,end_ts)).fetchall()
    vals=[float(r[1]) for r in rows if r[1] is not None]
    return sum(vals) if vals else None

def _warmest_coolest(conn,start_ts,end_ts,which):
    order="DESC" if which=="warmest" else "ASC"
    row=conn.execute(f"SELECT temp,ts FROM minute_obs WHERE ts>=? AND ts<=? AND temp IS NOT NULL ORDER BY temp {order} LIMIT 1",(start_ts,end_ts)).fetchone()
    return row

def _wettest_day(conn,start_ts,end_ts):
    row=conn.execute("""
        SELECT local_day, day_rain FROM (
          SELECT date(ts,'unixepoch','localtime') local_day, MAX(rain_today) day_rain
          FROM minute_obs WHERE ts>=? AND ts<=? AND rain_today IS NOT NULL
          GROUP BY local_day
        ) ORDER BY day_rain DESC LIMIT 1
    """,(start_ts,end_ts)).fetchone()
    return row

def build_history_stats(snapshot):
    now_dt = datetime.now()
    current_temp = _valid_num(snapshot.get("OutdoorTemp"))
    items=[]
    periods={}
    try:
        with _history_lock:
            conn=_history_conn()
            first=conn.execute("SELECT MIN(ts) FROM minute_obs").fetchone()[0]
            now_ts=int(now_dt.timestamp())

            target=int((now_dt-timedelta(hours=24)).timestamp())
            old=conn.execute("SELECT temp,ts FROM minute_obs WHERE ts BETWEEN ? AND ? ORDER BY ABS(ts-?) LIMIT 1",
                             (target-5400,target+5400,target)).fetchone()
            if current_temp is not None and old and old[0] is not None:
                change=round(current_temp)-round(old[0])
                items.append({"label":"24 HR Temp Change","value":f"{change:+d}°" if change else "0°"})

            # Bottom extremes browser payload. Daily remains powered by the parser's
            # live daily-extremes engine; longer periods come from the minute archive.
            periods["daily"]=_daily_extreme_payload()
            for period in ("week","month","year","all"):
                start_ts,end_ts=_period_bounds(now_dt,period)
                ex=_period_extremes(conn,start_ts,end_ts,True)
                if ex:
                    if period in ("week","month"):
                        ex["rainfall"]=_period_rainfall(conn,start_ts,end_ts)
                    elif period == "year":
                        ex["rainfall"]=_year_rainfall_with_seed(conn, now_dt.year, end_ts)
                    elif first:
                        ex["rainfall"]=_rolling_rainfall(conn,int(first),end_ts)
                    periods[period]=ex

            # Climate departures are high-value context, so put them near the front.
            items.extend(_climate_insights(conn,now_dt,periods))

            # Restore useful hard statistics to the insight rotation. Pressure stays
            # in the bottom browser to avoid consuming the line with less-interesting data.
            period_defs=[("daily","Day"),("week","Week"),("month","Month"),("year","Year"),("all","All-Time")]
            for key,title in period_defs:
                ex=periods.get(key)
                if not ex: continue
                specs=[
                    ("Temp High",ex.get("tempHigh"),"temp"),("Temp Low",ex.get("tempLow"),"temp"),
                    ("Dew High",ex.get("dewHigh"),"temp"),("Dew Low",ex.get("dewLow"),"temp"),
                    ("Max Heat Index",ex.get("feelsHigh"),"temp"),("Min Wind Chill",ex.get("feelsLow"),"temp"),
                    ("Max Gust",ex.get("gustHigh",ex.get("windHigh")),"wind"),
                    ("Max Avg Wind",ex.get("windAvgHigh"),"wind"),
                    ("Max Rain Rate (in/hr)",ex.get("rainRateHigh"),"rainrate"),
                ]
                for suffix,val,kind in specs:
                    if val is None: continue
                    if kind == "temp": value=f"{round(float(val))}°"
                    elif kind == "wind": value=f"{round(float(val))} MPH"
                    else: value=f"{float(val):.2f}"
                    items.append({"label":f"{title} {suffix}","value":value})

            # Rainfall totals/trends.
            for period,label in (("week","This Week Rainfall"),("month","This Month Rainfall"),("year","This Year Rainfall")):
                ex=periods.get(period)
                if ex and ex.get("rainfall") is not None:
                    items.append({"label":label+" (in)","value":f'{ex["rainfall"]:.2f}'})
            seven_start=int((now_dt-timedelta(days=7)).timestamp())
            seven=_rolling_rainfall(conn,seven_start,now_ts)
            if seven is not None:
                items.append({"label":"7-Day Rainfall (in)","value":f"{seven:.2f}"})

            week_start,_=_period_bounds(now_dt,"week")
            warm=_warmest_coolest(conn,week_start,now_ts,"warmest")
            cool=_warmest_coolest(conn,week_start,now_ts,"coolest")
            if warm:
                items.append({"label":"Week Warmest Temp","value":f"{round(warm[0])}°"})
            if cool:
                items.append({"label":"Week Coolest Temp","value":f"{round(cool[0])}°"})

            wet_periods=[("week","Week Wettest Day"),("month","Month Wettest Day"),("year","Year Wettest Day")]
            if first: wet_periods.append(("all","All-Time Wettest Day"))
            for wet_period,wet_title in wet_periods:
                wet_start,wet_end=(int(first),now_ts) if wet_period=="all" else _period_bounds(now_dt,wet_period)
                wet=_wettest_day(conn,wet_start,wet_end)
                if wet and wet[1] is not None:
                    try:
                        wet_dt=datetime.strptime(wet[0],"%Y-%m-%d")
                        if wet_dt.date()==now_dt.date(): wet_label="Today"
                        elif wet_period=="week": wet_label=wet_dt.strftime("%a")
                        elif wet_period=="all" and wet_dt.year!=now_dt.year: wet_label=wet_dt.strftime("%b %d, %Y").replace(" 0"," ")
                        else: wet_label=wet_dt.strftime("%b %d").replace(" 0"," ")
                    except Exception: wet_label=str(wet[0])
                    items.append({"label":wet_title,"value":f'{float(wet[1]):.2f}"'})

            py,pm=(now_dt.year-1,12) if now_dt.month==1 else (now_dt.year,now_dt.month-1)
            pday=min(now_dt.day,calendar.monthrange(py,pm)[1])
            lm=_day_temp_stats(conn,datetime(py,pm,pday))
            if lm:
                items.append({"label":"Last Month This Date High","value":f"{round(lm[1])}°"})
                items.append({"label":"Last Month This Date Low","value":f"{round(lm[0])}°"})
            lyday=min(now_dt.day,calendar.monthrange(now_dt.year-1,now_dt.month)[1])
            ly=_day_temp_stats(conn,datetime(now_dt.year-1,now_dt.month,lyday))
            if ly:
                items.append({"label":"Last Year This Date High","value":f"{round(ly[1])}°"})
                items.append({"label":"Last Year This Date Low","value":f"{round(ly[0])}°"})
            conn.close()
        return {"items":items,"periods":periods,"updatedEpoch":int(time.time()),"historyStartEpoch":first,
                "climateProfile": {"configured": bool((_load_climate_normals().get("daily") or {})), "file": WX_CLIMATE_NORMALS_FILE}}
    except Exception as e:
        print(f"WX HISTORY stats error: {e}")
        return {"items":[],"periods":{},"updatedEpoch":int(time.time()),"historyStartEpoch":None}

def history_thread():
    global _history_stats_cache
    while True:
        try:
            snap=dict(latest_weather)
            record_minute_history(snap)
            _history_stats_cache=build_history_stats(snap)
            latest_weather["HistoricalStats"]=_history_stats_cache
        except Exception as e:
            print(f"WX HISTORY thread error: {e}")
        time.sleep(10)

# Leave False for normal operation. Set True only when inspecting packet fields.
HUMIDITY_DEBUG = False

# Leave True while first testing the rain gauge. Once RainToday and RainRate are verified,
# change this to False to stop the extra rain debug prints.
RAIN_DEBUG = False

# ===== CWOP / APRS-IS SETTINGS =====
# Keep this False until you are ready to transmit.
CWOP_ENABLED = True

CWOP_STATION_ID = "CW0168"
CWOP_PASSCODE = "-1"  # No passcode supplied. Some CWOP/APRS-IS paths accept -1.
CWOP_SERVER = "cwop.aprs.net"
CWOP_PORT = 14580
CWOP_SEND_INTERVAL = 300  # 5 minutes

CWOP_LAT = 34.84
CWOP_LON = -87.67

# Optional offset so you do not always transmit exactly on the 5-minute mark.
CWOP_INITIAL_DELAY_SECONDS = 0
CWOP_SOFTWARE_TAG = "PeetWxPanel"

# ===== HAZCAMS UPLOAD SETTINGS =====
HAZCAMS_ENABLED = True
HAZCAMS_URL = "https://backend.hazcams.com"
HAZCAMS_STATION_ID = "florence-al-us-003"
HAZCAMS_SEND_INTERVAL = 1  # seconds
HAZCAMS_TIMEOUT_SECONDS = 10

# ===== WEATHER UNDERGROUND RAPIDFIRE SETTINGS =====
WU_ENABLED = True
WU_STATION_ID = "KALFLORE15"
WU_STATION_KEY = "865f7f79"
WU_SEND_INTERVAL = 5  # seconds
WU_TIMEOUT_SECONDS = 10
WU_URL = "https://weatherstation.wunderground.com/weatherstation/updateweatherstation.php"



# ===== WEATHER SOURCE MIXING / WEATHERLINK LIVE =====
# Each display/output metric can independently use PEET or a local Davis
# WeatherLink Live. Davis values bypass this parser's PEET 2-minute smoothing.
SOURCE_CONFIG_FILE = os.path.join(SCRIPT_DIR, "weather_source_config.json")
DEFAULT_SOURCE_CONFIG = {
    "weatherlink_live": {
        "enabled": True,
        "host": "192.168.1.66",
        "port": 80,
        "txid": 1,
        "poll_seconds": 10,
        "timeout_seconds": 3
    },
    "fallback_to_peet": True,
    "sources": {
        "temperature": "davis",
        "humidity": "davis",
        "dewpoint": "derived",
        "pressure": "peet",
        "wind_latest": "peet",
        "wind_average": "peet",
        "wind_direction": "peet",
        "gust": "peet",
        "rain": "peet"
    },
    "calibration": {
        "davis": {
            "temperature": {"enabled": False, "offset": 0.0, "multiplier": 1.0, "multiplier2": 0.0},
            "humidity": {"enabled": True, "offset": 8.0, "multiplier": 0.34, "multiplier2": 0.0061}
        },
        "peet": {
            "temperature": {"enabled": False, "offset": 0.0, "multiplier": 1.0, "multiplier2": 0.0},
            "humidity": {"enabled": False, "offset": 0.0, "multiplier": 1.0, "multiplier2": 0.0}
        }
    }
}

def load_weather_source_config():
    try:
        with open(SOURCE_CONFIG_FILE, "r", encoding="utf-8") as f:
            cfg = json.load(f)
    except Exception:
        cfg = json.loads(json.dumps(DEFAULT_SOURCE_CONFIG))
    # Merge missing keys so older configs continue working.
    merged = json.loads(json.dumps(DEFAULT_SOURCE_CONFIG))
    if isinstance(cfg, dict):
        merged.update({k: v for k, v in cfg.items() if k not in ("weatherlink_live", "sources", "calibration")})
        if isinstance(cfg.get("weatherlink_live"), dict):
            merged["weatherlink_live"].update(cfg["weatherlink_live"])
        if isinstance(cfg.get("sources"), dict):
            merged["sources"].update(cfg["sources"])
        if isinstance(cfg.get("calibration"), dict):
            for source in ("davis", "peet"):
                src_cfg = cfg["calibration"].get(source)
                if not isinstance(src_cfg, dict):
                    continue
                for metric in ("temperature", "humidity"):
                    metric_cfg = src_cfg.get(metric)
                    if isinstance(metric_cfg, dict):
                        merged["calibration"][source][metric].update(metric_cfg)
    return merged

def save_weather_source_config(cfg):
    with open(SOURCE_CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(cfg, f, indent=2)

WEATHER_SOURCE_CONFIG = load_weather_source_config()
# V064+: dew point is intentionally derived from the final selected/calibrated
# temperature and humidity. Migrate older configs that still say peet/davis.
WEATHER_SOURCE_CONFIG.setdefault("sources", {})["dewpoint"] = "derived"
_davis_lock = threading.Lock()
davis_weather = {
    "available": False,
    "last_success_epoch": None,
    "last_error": None,
    "values": {}
}

def _rain_tip_inches(rain_size):
    # Davis Local API rain_size: 1=.01in, 2=.2mm, 3=.1mm, 4=.001in
    return {1: 0.01, 2: 0.2 / 25.4, 3: 0.1 / 25.4, 4: 0.001}.get(rain_size)

def _finite_number(value):
    try:
        n = float(value)
        return n if math.isfinite(n) else None
    except Exception:
        return None

def parse_weatherlink_live_payload(payload, txid=None):
    data = payload.get("data") if isinstance(payload, dict) else None
    conditions = data.get("conditions", []) if isinstance(data, dict) else []
    if not isinstance(conditions, list):
        return {}

    iss = None
    bar = None
    for cond in conditions:
        if not isinstance(cond, dict):
            continue
        dst = cond.get("data_structure_type")
        if dst == 1 and iss is None:
            if txid is None or cond.get("txid") == txid:
                iss = cond
        if dst == 3:
            bar = cond
    # If configured txid was not present, use the first ISS as a safe fallback.
    if iss is None:
        for cond in conditions:
            if isinstance(cond, dict) and cond.get("data_structure_type") == 1:
                iss = cond
                break

    values = {}
    if iss:
        mapping = {
            "temperature": "temp",
            "humidity": "hum",
            "dewpoint": "dew_point",
            "wind_latest": "wind_speed_last",
            "wind_average": "wind_speed_avg_last_2_min",
            "wind_direction": "wind_dir_last",
            "wind_direction_average": "wind_dir_scalar_avg_last_2_min",
            "gust": "wind_speed_hi_last_10_min",
            "gust_bearing": "wind_dir_at_hi_speed_last_10_min",
        }
        for out_key, in_key in mapping.items():
            val = _finite_number(iss.get(in_key))
            if val is not None:
                values[out_key] = val

        rain_size = iss.get("rain_size")
        tip_inches = _rain_tip_inches(rain_size)
        if tip_inches is not None:
            rain_fields = {
                "rain_today": "rainfall_daily",
                "rain_1hr": "rainfall_last_60_min",
                "rain_24hr": "rainfall_last_24_hr",
                "rain_rate": "rain_rate_last",
            }
            for out_key, in_key in rain_fields.items():
                val = _finite_number(iss.get(in_key))
                if val is not None:
                    values[out_key] = val * tip_inches

    if bar:
        pressure = _finite_number(bar.get("bar_sea_level"))
        if pressure is not None:
            values["pressure"] = pressure
            values["pressure_mb"] = pressure / 0.029529983

    values["device_timestamp"] = data.get("ts") if isinstance(data, dict) else None
    return values

def weatherlink_live_thread():
    cfg = WEATHER_SOURCE_CONFIG.get("weatherlink_live", {})
    if not cfg.get("enabled", False):
        print("WeatherLink Live source disabled")
        return
    host = str(cfg.get("host") or "192.168.1.66")
    port = int(cfg.get("port") or 80)
    txid = cfg.get("txid")
    poll_seconds = max(10, int(cfg.get("poll_seconds") or 10))
    timeout_seconds = max(1, float(cfg.get("timeout_seconds") or 3))
    url = f"http://{host}:{port}/v1/current_conditions"
    print(f"WeatherLink Live polling {url} every {poll_seconds}s (txid={txid})")
    while True:
        try:
            req = urllib.request.Request(url, headers={"Accept": "application/json"})
            with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
                payload = json.loads(resp.read().decode("utf-8", errors="replace"))
            if payload.get("error"):
                raise RuntimeError(str(payload.get("error")))
            values = parse_weatherlink_live_payload(payload, txid=txid)
            if not values:
                raise RuntimeError("no ISS current-condition values returned")
            with _davis_lock:
                davis_weather["available"] = True
                davis_weather["last_success_epoch"] = int(time.time())
                davis_weather["last_error"] = None
                davis_weather["values"] = values
        except Exception as e:
            with _davis_lock:
                davis_weather["available"] = False
                davis_weather["last_error"] = str(e)
        time.sleep(poll_seconds)

def get_davis_snapshot():
    with _davis_lock:
        return {
            "available": bool(davis_weather.get("available")),
            "last_success_epoch": davis_weather.get("last_success_epoch"),
            "last_error": davis_weather.get("last_error"),
            "values": dict(davis_weather.get("values") or {})
        }

def apply_metric_calibration(metric, value, actual_source):
    """Apply the same quadratic calibration model used by CumulusMX.

    corrected = multiplier2 * raw^2 + multiplier * raw + offset

    Calibration is source-specific and currently exposed for temperature/humidity.
    Davis readings still bypass PEET's 2-minute smoothing; calibration is applied
    only after a source value has been selected.
    """
    if value is None or metric not in ("temperature", "humidity"):
        return value, None
    source = "davis" if str(actual_source).startswith("davis") else "peet"
    cfg = (WEATHER_SOURCE_CONFIG.get("calibration", {})
           .get(source, {})
           .get(metric, {}))
    raw = _finite_number(value)
    if raw is None:
        return value, None
    enabled = bool(cfg.get("enabled", False))
    if not enabled:
        return raw, {"enabled": False, "source": source, "raw": raw, "corrected": raw}
    offset = _finite_number(cfg.get("offset"))
    mult = _finite_number(cfg.get("multiplier"))
    mult2 = _finite_number(cfg.get("multiplier2"))
    offset = 0.0 if offset is None else offset
    mult = 1.0 if mult is None else mult
    mult2 = 0.0 if mult2 is None else mult2
    corrected = (mult2 * raw * raw) + (mult * raw) + offset
    if metric == "humidity":
        corrected = max(0.0, min(100.0, corrected))
    return corrected, {
        "enabled": True, "source": source, "raw": raw, "corrected": corrected,
        "offset": offset, "multiplier": mult, "multiplier2": mult2
    }


def select_metric_source(metric, peet_value, davis_key=None):
    desired = str(WEATHER_SOURCE_CONFIG.get("sources", {}).get(metric, "peet")).lower()
    if desired == "davis":
        ds = get_davis_snapshot()
        value = ds["values"].get(davis_key or metric) if ds["available"] else None
        if value is not None:
            return value, "davis"
        if not WEATHER_SOURCE_CONFIG.get("fallback_to_peet", True):
            return None, "davis-unavailable"
        return peet_value, "peet-fallback"
    return peet_value, "peet"




def blank_daily_extremes():
    return {
        "HighTempToday": None,
        "LowTempToday": None,
        "HighFeelsLikeToday": None,
        "LowFeelsLikeToday": None,
        "HighPressToday": None,
        "LowPressToday": None,
        "HighGustToday": None,
        "HighAvgWindToday": None,
        "HighGustBearingToday": None,
        "HighHumToday": None,
        "LowHumToday": None,
        "HighDewpointToday": None,
        "LowDewpointToday": None,
        "HighRainRateToday": None,
    }


daily_date = time.strftime("%Y-%m-%d")
daily_extremes = blank_daily_extremes()

latest_weather = {
    "OutdoorTemp": "--.-",
    "OutdoorHum": "--",
    "OutdoorDewpoint": "--.-",
    "FeelsLike": "--.-",
    "WindLatest": "--",
    "WindAverage": "--",
    "Pressure": "--.--",
    "Bearing": 0,
    "Recentmaxgust": "--.-",

    # Rain placeholders for existing WxPanel compatibility.
    # We will wire real rain parsing tomorrow.
    "RainToday": "--.--",
    "RainRate": "--.--",
    "RainLast24Hours": "--.--",
    "RainLastHour": "--.--",
    "HighRainRateToday": "--.--",
    "LowRainRateToday": "",

    "HighTempToday": "--.-",
    "LowTempToday": "--.-",
    "HighFeelsLikeToday": "--.-",
    "LowFeelsLikeToday": "--.-",
    "HighPressToday": "--.--",
    "LowPressToday": "--.--",
    "HighGustToday": "--",
    "HighAvgWindToday": "--",
    "HighGustBearingToday": None,
    "HighHumToday": "--",
    "LowHumToday": "--",
    "HighDewpointToday": "--.-",
    "LowDewpointToday": "--.-",

    "LastDataRead": "--:--:--",
    "LastDataReadDate": time.strftime("%m/%d/%Y"),
    "LastDataReadEpoch": None,
    "PressureMB": None,
    "CWOPEnabled": CWOP_ENABLED,
    "CWOPLastSend": "--",
    "CWOPLastStatus": "disabled",
    "CWOPLastPacket": "",
    "HazcamsEnabled": HAZCAMS_ENABLED,
    "HazcamsLastSend": "--",
    "HazcamsLastStatus": "disabled",
    "WUEnabled": WU_ENABLED,
    "WULastSend": "--",
    "WULastStatus": "disabled",
}

wind_2min_samples = deque()
gust_10min_samples = deque()
wind_dir_2min_samples = deque()

# V062 per-minute archive peaks. Updated on every parser packet so the 1-minute
# SQLite row preserves peaks that could occur between history-thread polls.
wind_archive_peak_minute = None
wind_archive_max_2min_avg = None
wind_archive_max_10min_gust = None

# 2-minute display smoothing for sensor values that naturally wiggle.
temp_2min_samples = deque()
humidity_2min_samples = deque()
dewpoint_2min_samples = deque()

# Rain rate tracking. Peet rain totals are decoded from packet fields below.
rain_rate_samples = deque()
rain_total_rate_samples = deque()
rain_tip_samples = deque()
rain_24hr_samples = deque()
rain_1hr_samples = deque()
last_rain_total = None
last_rain_ts = None
last_rain_tip_ts = None
rain_rate_hold_value = 0.0
rain_rate_display = 0.0
last_hazcams_rain_total = None


def calc_dewpoint_f(temp_f, humidity):
    if humidity is None or humidity <= 0 or humidity > 100:
        return None

    temp_c = (temp_f - 32) * 5 / 9
    a = 17.27
    b = 237.7
    alpha = ((a * temp_c) / (b + temp_c)) + math.log(humidity / 100.0)
    dew_c = (b * alpha) / (a - alpha)
    return (dew_c * 9 / 5) + 32


def calc_wind_chill_f(temp_f, wind_mph):
    return (
        35.74
        + (0.6215 * temp_f)
        - (35.75 * (wind_mph ** 0.16))
        + (0.4275 * temp_f * (wind_mph ** 0.16))
    )


def calc_heat_index_f(temp_f, humidity):
    return (
        -42.379
        + 2.04901523 * temp_f
        + 10.14333127 * humidity
        - 0.22475541 * temp_f * humidity
        - 0.00683783 * temp_f * temp_f
        - 0.05481717 * humidity * humidity
        + 0.00122874 * temp_f * temp_f * humidity
        + 0.00085282 * temp_f * humidity * humidity
        - 0.00000199 * temp_f * temp_f * humidity * humidity
    )


def calc_feels_like_f(temp_f, humidity, live_wind_mph):
    if temp_f <= 50 and live_wind_mph >= 3:
        return calc_wind_chill_f(temp_f, live_wind_mph)

    # Match CWOP-style heat index behavior by allowing heat index at/above 80F
    # without requiring humidity to be at least 40%.
    if humidity is not None and temp_f >= 80:
        return calc_heat_index_f(temp_f, humidity)

    return temp_f


def save_daily_extremes():
    try:
        with open(DATA_FILE, "w") as f:
            json.dump({"date": daily_date, "values": daily_extremes}, f)
    except Exception as e:
        print(f"Could not save daily extremes: {e}")


def load_daily_extremes():
    global daily_extremes

    if not os.path.exists(DATA_FILE):
        return

    try:
        with open(DATA_FILE, "r") as f:
            data = json.load(f)

        if data.get("date") == daily_date:
            daily_extremes.update(data.get("values", {}))
    except Exception as e:
        print(f"Could not load daily extremes: {e}")


load_daily_extremes()


def load_gust_samples():
    """
    Restores recent 10-minute gust samples after a parser restart.

    Only samples from the last 10 minutes are kept, so old gusts naturally expire.
    """
    if not os.path.exists(GUST_FILE):
        return

    try:
        with open(GUST_FILE, "r") as f:
            data = json.load(f)

        now_ts = time.time()
        samples = data.get("samples", [])

        for ts, wind in samples:
            try:
                ts = float(ts)
                wind = float(wind)
            except Exception:
                continue

            if 0 <= now_ts - ts <= TEN_MINUTES and 0 <= wind <= 150:
                gust_10min_samples.append((ts, wind))

    except Exception as e:
        print(f"Could not load 10-minute gust samples: {e}")


def save_gust_samples(now_ts):
    """
    Saves recent 10-minute gust samples so a quick parser restart does not reset
    the displayed 10-minute gust.
    """
    try:
        remove_old_samples(gust_10min_samples, TEN_MINUTES, now_ts)

        with open(GUST_FILE, "w") as f:
            json.dump(
                {
                    "saved_at": now_ts,
                    "samples": list(gust_10min_samples),
                },
                f,
            )

    except Exception as e:
        print(f"Could not save 10-minute gust samples: {e}")


load_gust_samples()


@app.route("/")
def serve_panel():
    return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "peet_wxpanel.html")


@app.route("/wxpanel_history_manager.html")
def wxpanel_history_manager():
    return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "wxpanel_history_manager.html")

@app.route("/wxpanel_climate_manager.html")
def wxpanel_climate_manager():
    return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "wxpanel_climate_manager.html")

@app.route("/api/wxpanel/climate-normals", methods=["GET", "POST"])
def wxpanel_climate_normals_api():
    if request.method == "GET":
        return jsonify({"ok":True,"data":_load_climate_normals(),"file":WX_CLIMATE_NORMALS_FILE})
    payload=request.get_json(silent=True) or {}
    clean={"stationName":str(payload.get("stationName") or "").strip(),
           "referenceStation":str(payload.get("referenceStation") or "").strip(),"daily":{},"monthly":{}}
    for key,row in (payload.get("daily") or {}).items():
        if not isinstance(row,dict): continue
        try:
            datetime.strptime("2000-"+key,"%Y-%m-%d")
        except Exception: continue
        out={}
        for fld in ("precip","high","low","mean"):
            v=_valid_num(row.get(fld))
            if v is not None: out[fld]=round(v,3)
        if out: clean["daily"][key]=out
    for key,row in (payload.get("monthly") or {}).items():
        try: m=int(key)
        except Exception: continue
        if not (1<=m<=12) or not isinstance(row,dict): continue
        out={}
        for fld in ("precip","high","low","mean"):
            v=_valid_num(row.get(fld))
            if v is not None: out[fld]=round(v,3)
        if out: clean["monthly"][str(m)]=out
    clean["updatedEpoch"]=int(time.time())
    _save_climate_normals(clean)
    global _history_stats_cache
    try:
        _history_stats_cache=build_history_stats(dict(latest_weather)); latest_weather["HistoricalStats"]=_history_stats_cache
    except Exception as e: print(f"Climate normals refresh error: {e}")
    return jsonify({"ok":True,"data":clean})

@app.route("/api/wxpanel/rainfall-seed", methods=["GET", "POST"])
def wxpanel_rainfall_seed_api():
    if request.method == "GET":
        return jsonify({"ok": True, "data": _load_rainfall_seed(), "file": WX_RAIN_SEED_FILE})
    payload=request.get_json(silent=True) or {}
    years=payload.get("years") or {}
    clean={"years": {}}
    for year, months in years.items():
        try:
            y=int(year)
        except Exception:
            continue
        if y < 1900 or y > 2200 or not isinstance(months, dict):
            continue
        out={}
        for month, value in months.items():
            try:
                m=int(month); n=float(value)
                if 1 <= m <= 12 and math.isfinite(n) and n >= 0:
                    out[str(m)]=round(n, 3)
            except Exception:
                pass
        if out:
            clean["years"][str(y)]=out
    clean["updatedEpoch"]=int(time.time())
    _save_rainfall_seed(clean)
    # Refresh insight/extremes cache immediately after an operator save.
    global _history_stats_cache
    try:
        _history_stats_cache=build_history_stats(dict(latest_weather))
        latest_weather["HistoricalStats"]=_history_stats_cache
    except Exception as e:
        print(f"Rainfall seed refresh error: {e}")
    return jsonify({"ok": True, "data": clean})

@app.after_request
def add_cors_headers(response):
    response.headers["Access-Control-Allow-Origin"] = "*"
    return response


def hex_to_int_4(value):
    if value is None:
        return None

    value = value.strip().upper()

    if len(value) != 4:
        return None

    if value == "----":
        return None

    try:
        return int(value, 16)
    except ValueError:
        return None




def load_rain_24hr_samples():
    """
    Restore rain-total samples used for rolling 24-hour rainfall after restart.
    The values are RainToday totals, so midnight resets are handled in the
    rolling calculator.
    """
    if not os.path.exists(RAIN_HISTORY_FILE):
        return

    try:
        with open(RAIN_HISTORY_FILE, "r") as f:
            data = json.load(f)

        now_ts = time.time()
        samples = data.get("samples", [])

        for ts, total in samples:
            try:
                ts = float(ts)
                total = float(total)
            except Exception:
                continue

            if 0 <= now_ts - ts <= RAIN_24HR_SECONDS:
                rain_24hr_samples.append((ts, total))

    except Exception as e:
        print(f"Could not load 24-hour rain history: {e}")


def save_rain_24hr_samples(now_ts):
    """
    Save rolling 24-hour rain history so CWOP p### survives restarts.
    """
    try:
        remove_old_samples(rain_24hr_samples, RAIN_24HR_SECONDS, now_ts)

        with open(RAIN_HISTORY_FILE, "w") as f:
            json.dump(
                {
                    "saved_at": now_ts,
                    "samples": list(rain_24hr_samples),
                },
                f,
            )

    except Exception as e:
        print(f"Could not save 24-hour rain history: {e}")


def update_rain_24hr_total(rain_today, now_ts):
    """
    Maintains and returns a rolling 24-hour rain total.

    Because Peet's RainToday resets at midnight, we calculate rainfall by adding
    only positive increases between stored samples. If the counter drops, that is
    treated as a reset, not negative rain.
    """
    if rain_today is None:
        return 0.0

    try:
        rain_today = float(rain_today)
    except Exception:
        return 0.0

    rain_24hr_samples.append((now_ts, rain_today))
    remove_old_samples(rain_24hr_samples, RAIN_24HR_SECONDS, now_ts)

    if len(rain_24hr_samples) < 2:
        return 0.0

    total = 0.0
    previous_total = rain_24hr_samples[0][1]

    for _, current_total in list(rain_24hr_samples)[1:]:
        delta = current_total - previous_total

        if delta > 0:
            total += delta

        # If delta is negative, Peet likely reset at midnight. Do not subtract.
        previous_total = current_total

    return max(0.0, total)



def update_rain_1hr_total(rain_today, now_ts):
    """
    Maintains and returns rolling 1-hour rain total for Weather Underground rainin.
    WU rainin expects rain over the last hour, not current rain rate.
    """
    if rain_today is None:
        return 0.0

    try:
        rain_today = float(rain_today)
    except Exception:
        return 0.0

    rain_1hr_samples.append((now_ts, rain_today))
    remove_old_samples(rain_1hr_samples, 3600, now_ts)

    if len(rain_1hr_samples) < 2:
        return 0.0

    total = 0.0
    previous_total = rain_1hr_samples[0][1]

    for _, current_total in list(rain_1hr_samples)[1:]:
        delta = current_total - previous_total
        if delta > 0:
            total += delta
        previous_total = current_total

    return max(0.0, total)


load_rain_24hr_samples()


def decode_rain_from_chunks(chunks):
    """
    Peet serial data uses rain units of 0.01 inches.

    For Data Logger style packets:
      field 4  = long-term rain total = chunks[3]
      field 11 = today's rain total    = chunks[10]

    We use today's rain total for RainToday when present.
    We keep long-term rain as a fallback / debug candidate.
    """

    candidates = []

    # Field 4: long-term rain total, 0.01 inch units.
    if len(chunks) >= 4:
        raw = hex_to_int_4(chunks[3])
        if raw is not None:
            value = raw / 100
            if 0 <= value <= 1000:
                candidates.append(("field_4_long_term_rain", chunks[3], value))

    # Field 11: today's rain total, 0.01 inch units.
    if len(chunks) >= 11:
        raw = hex_to_int_4(chunks[10])
        if raw is not None:
            value = raw / 100
            if 0 <= value <= 100:
                candidates.append(("field_11_today_rain", chunks[10], value))

    today_rain = None
    long_term_rain = None

    for source, raw_hex, value in candidates:
        if source == "field_11_today_rain":
            today_rain = value
        elif source == "field_4_long_term_rain":
            long_term_rain = value

    # Prefer today's rain if available. Fall back to long-term rain only if needed.
    selected = today_rain if today_rain is not None else long_term_rain

    return selected, long_term_rain, candidates


def calc_rain_rate_in_per_hr(rain_total, now_ts):
    """
    Davis-ish tipping-bucket rain rate with smarter decay.

    Behavior:
      - On each bucket tip: calculate rate from time between tips.
      - Between tips: do not instantly decay every packet.
      - After a short grace period, cap the displayed rate by the maximum rate
        still possible based on time since the last tip.
      - After 15 minutes without a tip, drop to 0.00.

    Example with 0.01" tips:
      - 18 sec between tips = 2.00 in/hr
      - 55 sec between tips = 0.65 in/hr
      - 5 min since last tip means rate cannot still be above 0.12 in/hr
      - 15 min since last tip = 0.00 in/hr
    """

    global last_rain_total, last_rain_ts, last_rain_tip_ts, rain_rate_hold_value

    if rain_total is None:
        return 0.0

    try:
        rain_total = float(rain_total)
    except Exception:
        return 0.0

    # First valid reading: establish baseline without inventing rain rate.
    if last_rain_total is None:
        last_rain_total = rain_total
        last_rain_ts = now_ts
        return rain_rate_hold_value

    # Midnight reset or counter wrap.
    if rain_total < last_rain_total:
        last_rain_total = rain_total
        last_rain_ts = now_ts
        last_rain_tip_ts = None
        rain_rate_hold_value = 0.0
        rain_tip_samples.clear()
        rain_rate_samples.clear()
        rain_total_rate_samples.clear()
        return rain_rate_hold_value

    delta = rain_total - last_rain_total

    if delta > 0:
        if last_rain_tip_ts is not None:
            dt_tip = now_ts - last_rain_tip_ts

            if dt_tip > 0:
                new_rate = (delta / dt_tip) * 3600.0

                # Clamp obvious junk, but allow true downpour values.
                if 0 <= new_rate <= 20:
                    rain_rate_hold_value = new_rate

        else:
            # First tip after startup: use Davis minimum measurable rate.
            rain_rate_hold_value = RAIN_TIP_SIZE_INCHES / (RAIN_RATE_HOLD_SECONDS / 3600.0)

        last_rain_tip_ts = now_ts
        last_rain_total = rain_total
        last_rain_ts = now_ts
        return rain_rate_hold_value

    # No new tip. Hold briefly, then step down based on time since last tip.
    if last_rain_tip_ts is not None:
        seconds_since_tip = now_ts - last_rain_tip_ts

        if seconds_since_tip >= RAIN_RATE_HOLD_SECONDS:
            rain_rate_hold_value = 0.0
        elif seconds_since_tip > 60:
            # Max possible current rate if the next 0.01" tip has not happened yet.
            # This prevents heavy rain rates from sticking too long after rain ends.
            max_possible_rate = RAIN_TIP_SIZE_INCHES / (seconds_since_tip / 3600.0)

            if rain_rate_hold_value > max_possible_rate:
                rain_rate_hold_value = max_possible_rate

    last_rain_ts = now_ts
    return rain_rate_hold_value


def avg_wind_direction_from_samples(sample_deque):
    """
    Vector-average wind direction so wraparound behaves correctly.
    Example: 359 and 001 average to 000, not 180.
    """
    if not sample_deque:
        return None

    sin_sum = 0.0
    cos_sum = 0.0

    for _, degrees in sample_deque:
        radians = math.radians(degrees)
        sin_sum += math.sin(radians)
        cos_sum += math.cos(radians)

    if sin_sum == 0 and cos_sum == 0:
        return None

    avg = math.degrees(math.atan2(sin_sum, cos_sum))

    if avg < 0:
        avg += 360

    return avg


def decode_humidity_from_chunks(chunks):
    """
    Official Peet data units say humidity is 0.1%.

    For Data Logger Mode:
      field 7 = outdoor humidity = chunks[6]

    For Packet Mode:
      field 9 = outdoor humidity = chunks[8]

    Your current live stream starts with "!" and is being parsed as chunked hex fields.
    The previous code was using tail_chunks[1] / 22, which can get stuck on the wrong field.
    This version uses the official field position first.
    """

    candidates = []

    # Data Logger Mode field 7.
    if len(chunks) >= 7:
        raw = hex_to_int_4(chunks[6])
        if raw is not None:
            value = raw / 10
            if 0 <= value <= 100:
                candidates.append(("data_logger_field_7", chunks[6], value))

    # Packet Mode field 9.
    if len(chunks) >= 9:
        raw = hex_to_int_4(chunks[8])
        if raw is not None:
            value = raw / 10
            if 0 <= value <= 100:
                candidates.append(("packet_field_9", chunks[8], value))

    # Fallback only: old tail-style field, but using official 0.1% units first.
    if "----" in "".join(chunks):
        joined = "".join(chunks)
        tail = joined.split("----", 1)[1]
        tail_chunks = [tail[i:i + 4] for i in range(0, len(tail), 4)]

        for idx, chunk in enumerate(tail_chunks[:3]):
            raw = hex_to_int_4(chunk)
            if raw is not None:
                value = raw / 10
                if 0 <= value <= 100:
                    candidates.append((f"tail_after_dash_{idx}", chunk, value))

    if not candidates:
        return None, []

    # Prefer the official Data Logger field first.
    priority = {
        "data_logger_field_7": 0,
        "packet_field_9": 1,
        "tail_after_dash_0": 2,
        "tail_after_dash_1": 3,
        "tail_after_dash_2": 4,
    }

    candidates.sort(key=lambda c: priority.get(c[0], 99))
    source, raw_hex, value = candidates[0]

    return round(value), candidates


def parse_packet(packet):
    try:
        # Remove common headers but keep the 4-character field alignment.
        packet = packet.strip()
        packet = packet.lstrip("!")
        packet = packet.lstrip("|")

        chunks = [packet[i:i + 4] for i in range(0, len(packet), 4)]

        if len(chunks) < 5:
            return None

        raw_wind_value = hex_to_int_4(chunks[0])
        direction_raw = hex_to_int_4(chunks[1])
        temp_raw = hex_to_int_4(chunks[2])
        pressure_raw = hex_to_int_4(chunks[4])

        if raw_wind_value is None or direction_raw is None or temp_raw is None or pressure_raw is None:
            return None

        raw_wind = raw_wind_value / 10
        wind = raw_wind / WIND_CALIBRATION_FACTOR

        direction = direction_raw * 360 / 256
        temp = temp_raw / 10

        pressure_mb = pressure_raw / 10
        pressure = pressure_mb * 0.029529983

        humidity, humidity_candidates = decode_humidity_from_chunks(chunks)
        rain_today, rain_long_term, rain_candidates = decode_rain_from_chunks(chunks)

        dewpoint = calc_dewpoint_f(temp, humidity)
        feels_like = calc_feels_like_f(temp, humidity, wind)

        # Reject glitch packets before they can poison daily records.
        if not (0 <= wind <= 150):
            return None
        if not (0 <= direction <= 360):
            return None
        if not (-40 <= temp <= 130):
            return None
        if not (25 <= pressure <= 33):
            return None
        if humidity is not None and not (0 <= humidity <= 100):
            return None
        if dewpoint is not None and not (-80 <= dewpoint <= 100):
            return None
        if feels_like is not None and not (-80 <= feels_like <= 140):
            return None
        if rain_today is not None and not (0 <= rain_today <= 100):
            return None
        if rain_long_term is not None and not (0 <= rain_long_term <= 1000):
            return None

        return wind, direction, temp, pressure, humidity, dewpoint, feels_like, rain_today, rain_long_term, chunks, humidity_candidates, rain_candidates

    except Exception:
        return None


def remove_old_samples(sample_deque, max_age_seconds, now):
    while sample_deque and now - sample_deque[0][0] > max_age_seconds:
        sample_deque.popleft()


def avg_from_samples(sample_deque):
    if not sample_deque:
        return None

    return sum(sample[1] for sample in sample_deque) / len(sample_deque)


def format_daily_values():
    return {
        "HighTempToday": f"{daily_extremes['HighTempToday']:.1f}" if daily_extremes["HighTempToday"] is not None else "--.-",
        "LowTempToday": f"{daily_extremes['LowTempToday']:.1f}" if daily_extremes["LowTempToday"] is not None else "--.-",
        "HighFeelsLikeToday": f"{daily_extremes['HighFeelsLikeToday']:.1f}" if daily_extremes["HighFeelsLikeToday"] is not None else "--.-",
        "LowFeelsLikeToday": f"{daily_extremes['LowFeelsLikeToday']:.1f}" if daily_extremes["LowFeelsLikeToday"] is not None else "--.-",
        "HighPressToday": f"{daily_extremes['HighPressToday']:.2f}" if daily_extremes["HighPressToday"] is not None else "--.--",
        "LowPressToday": f"{daily_extremes['LowPressToday']:.2f}" if daily_extremes["LowPressToday"] is not None else "--.--",
        "HighGustToday": f"{daily_extremes['HighGustToday']:.1f}" if daily_extremes["HighGustToday"] is not None else "--",
        "HighAvgWindToday": f"{daily_extremes['HighAvgWindToday']:.1f}" if daily_extremes.get("HighAvgWindToday") is not None else "--",
        "HighGustBearingToday": daily_extremes["HighGustBearingToday"],
        "HighHumToday": f"{daily_extremes['HighHumToday']:.0f}" if daily_extremes["HighHumToday"] is not None else "--",
        "LowHumToday": f"{daily_extremes['LowHumToday']:.0f}" if daily_extremes["LowHumToday"] is not None else "--",
        "HighDewpointToday": f"{daily_extremes['HighDewpointToday']:.1f}" if daily_extremes["HighDewpointToday"] is not None else "--.-",
        "LowDewpointToday": f"{daily_extremes['LowDewpointToday']:.1f}" if daily_extremes["LowDewpointToday"] is not None else "--.-",
        "HighRainRateToday": f"{daily_extremes['HighRainRateToday']:.2f}" if daily_extremes["HighRainRateToday"] is not None else "--.--",
        "LowRainRateToday": "",
    }


def update_daily_extremes(temp, pressure, wind, direction, humidity, dewpoint, feels_like, rain_rate=None, avg_wind=None):
    global daily_date, daily_extremes

    today = time.strftime("%Y-%m-%d")

    if today != daily_date:
        daily_date = today
        daily_extremes = blank_daily_extremes()
        save_daily_extremes()

    changed = False

    checks = [
        ("HighTempToday", temp, "high"),
        ("LowTempToday", temp, "low"),
        ("HighFeelsLikeToday", feels_like, "high"),
        ("LowFeelsLikeToday", feels_like, "low"),
        ("HighPressToday", pressure, "high"),
        ("LowPressToday", pressure, "low"),
    ]

    if humidity is not None:
        checks.extend([
            ("HighHumToday", humidity, "high"),
            ("LowHumToday", humidity, "low"),
        ])

    if dewpoint is not None:
        checks.extend([
            ("HighDewpointToday", dewpoint, "high"),
            ("LowDewpointToday", dewpoint, "low"),
        ])

    if rain_rate is not None:
        checks.append(("HighRainRateToday", rain_rate, "high"))

    for key, value, mode in checks:
        if value is None:
            continue

        if daily_extremes[key] is None:
            daily_extremes[key] = value
            changed = True
        elif mode == "high" and value > daily_extremes[key]:
            daily_extremes[key] = value
            changed = True
        elif mode == "low" and value < daily_extremes[key]:
            daily_extremes[key] = value
            changed = True

    if daily_extremes["HighGustToday"] is None or wind > daily_extremes["HighGustToday"]:
        daily_extremes["HighGustToday"] = wind
        daily_extremes["HighGustBearingToday"] = int(direction)
        changed = True
    if avg_wind is not None and (daily_extremes.get("HighAvgWindToday") is None or avg_wind > daily_extremes.get("HighAvgWindToday")):
        daily_extremes["HighAvgWindToday"] = avg_wind
        changed = True

    if changed:
        save_daily_extremes()

    return format_daily_values()



def aprs_lat(lat):
    hemisphere = "N" if lat >= 0 else "S"
    lat = abs(lat)
    degrees = int(lat)
    minutes = (lat - degrees) * 60
    return f"{degrees:02d}{minutes:05.2f}{hemisphere}"


def aprs_lon(lon):
    hemisphere = "E" if lon >= 0 else "W"
    lon = abs(lon)
    degrees = int(lon)
    minutes = (lon - degrees) * 60
    return f"{degrees:03d}{minutes:05.2f}{hemisphere}"


def clamp_int(value, low, high, default=0):
    try:
        value = int(round(float(value)))
    except Exception:
        return default
    return max(low, min(high, value))


def aprs_temp_f(temp_f):
    temp = clamp_int(temp_f, -99, 999, 0)
    if temp < 0:
        return f"-{abs(temp):02d}"
    return f"{temp:03d}"


def aprs_humidity(humidity):
    hum = clamp_int(humidity, 0, 100, 0)
    if hum >= 100:
        return "00"
    return f"{hum:02d}"


def hundredths_inches(value):
    try:
        return clamp_int(float(value) * 100, 0, 999, 0)
    except Exception:
        return 0


def build_cwop_packet(snapshot):
    """
    Builds a standard APRS weather packet for CWOP.

    Included:
      - wind direction
      - 2-minute average wind speed
      - 10-minute gust
      - 2-minute average temperature
      - rain since midnight
      - humidity
      - pressure in tenths of millibars

    We send r000 and p000 until hourly/24-hour rain storage is added.
    Pxxx is today's rain since midnight.
    """

    temp_f = snapshot.get("OutdoorTemp")
    humidity = snapshot.get("OutdoorHum")
    pressure_mb = snapshot.get("PressureMB")
    rain_today = snapshot.get("RainToday")
    rain_24hr = snapshot.get("RainLast24Hours")
    wind_avg = snapshot.get("WindAverage")
    gust = snapshot.get("Recentmaxgust")
    bearing = snapshot.get("WindBearingAverage") or snapshot.get("Bearing")

    if temp_f in (None, "--.-") or humidity in (None, "--") or pressure_mb in (None, "--"):
        return None

    wind_dir = clamp_int(bearing, 0, 360, 0)
    if wind_dir == 360:
        wind_dir = 0

    wind_speed = clamp_int(wind_avg, 0, 999, 0)
    wind_gust = clamp_int(gust, 0, 999, 0)

    temp_part = aprs_temp_f(temp_f)
    hum_part = aprs_humidity(humidity)
    pressure_part = clamp_int(float(pressure_mb) * 10, 0, 99999, 0)
    rain_today_hundredths = hundredths_inches(rain_today if rain_today not in (None, "--.--") else 0)

    # APRS/CWOP rain fields:
    # r = rain in last hour
    # p = rain in last 24 hours
    # P = rain since midnight
    rain_24hr_hundredths = hundredths_inches(
        rain_24hr if rain_24hr not in (None, "--.--") else rain_today
    )

    timestamp = time.strftime("%d%H%M", time.gmtime())
    lat = aprs_lat(CWOP_LAT)
    lon = aprs_lon(CWOP_LON)

    packet = (
        f"{CWOP_STATION_ID}>APRS,TCPIP*:."
        f"{timestamp}z"
        f"{lat}/{lon}_"
        f"{wind_dir:03d}/{wind_speed:03d}"
        f"g{wind_gust:03d}"
        f"t{temp_part}"
        f"r000"
        f"p{rain_24hr_hundredths:03d}"
        f"P{rain_today_hundredths:03d}"
        f"h{hum_part}"
        f"b{pressure_part:05d}"
        f".{CWOP_SOFTWARE_TAG}"
    )

    return packet


def send_cwop_packet(packet):
    login = (
        f"user {CWOP_STATION_ID} pass {CWOP_PASSCODE} "
        f"vers {CWOP_SOFTWARE_TAG} 1.0\r\n"
    )

    with socket.create_connection((CWOP_SERVER, CWOP_PORT), timeout=15) as sock:
        sock.sendall(login.encode("ascii"))
        time.sleep(0.3)
        sock.sendall((packet + "\r\n").encode("ascii"))



def seconds_until_next_cwop_mark():
    """
    Returns seconds until the next real 5-minute clock mark.
    Example send marks: :00, :05, :10, :15, etc.
    """
    now = time.time()
    return CWOP_SEND_INTERVAL - (now % CWOP_SEND_INTERVAL)


def cwop_thread():
    global latest_weather

    while True:
        if not CWOP_ENABLED:
            latest_weather["CWOPEnabled"] = False
            latest_weather["CWOPLastStatus"] = "disabled"
            time.sleep(10)
            continue

        # Align CWOP sends to real 5-minute clock marks:
        # :00, :05, :10, :15, etc.
        wait_seconds = seconds_until_next_cwop_mark()

        if wait_seconds > 0:
            latest_weather["CWOPEnabled"] = True
            latest_weather["CWOPLastStatus"] = f"waiting for next 5-min mark ({int(wait_seconds)}s)"
            time.sleep(wait_seconds)

        if not CWOP_ENABLED:
            continue

        try:
            snapshot = dict(latest_weather)
            packet = build_cwop_packet(snapshot)

            if not packet:
                latest_weather["CWOPLastStatus"] = "waiting for valid data"
                time.sleep(5)
                continue

            send_cwop_packet(packet)

            now = time.localtime()
            latest_weather["CWOPEnabled"] = True
            latest_weather["CWOPLastSend"] = time.strftime("%I:%M:%S %p", now).lstrip("0")
            latest_weather["CWOPLastStatus"] = "sent"
            latest_weather["CWOPLastPacket"] = packet

            print(f"CWOP SENT | {packet}")

        except Exception as e:
            latest_weather["CWOPEnabled"] = True
            latest_weather["CWOPLastStatus"] = f"error: {e}"
            print(f"CWOP ERROR | {e}")

        # Small pause keeps us from double-sending during the same clock second.
        time.sleep(2)


def parse_float_or_none(value):
    if value in (None, "", "--", "--.-", "--.--"):
        return None
    try:
        return float(value)
    except Exception:
        return None



def get_hazcams_rain_delta(rain_today):
    """
    Hazcams treats rain_min as incremental rainfall added to backend totals.
    Send only rainfall since the previous Hazcams upload.
    """

    global last_hazcams_rain_total

    if rain_today in (None, "", "--", "--.-", "--.--"):
        return 0.0

    try:
        rain_today = float(rain_today)
    except Exception:
        return 0.0

    if last_hazcams_rain_total is None:
        last_hazcams_rain_total = rain_today
        return 0.0

    delta = rain_today - last_hazcams_rain_total
    last_hazcams_rain_total = rain_today

    # Midnight or counter reset.
    if delta < 0:
        return 0.0

    # Reject impossible single-upload jump.
    if delta > 2:
        return 0.0

    return round(delta, 4)


def build_hazcams_payload(snapshot):
    """
    Builds the Hazcams weather payload using the same field names as the
    existing Hazcams/Austin station parsers.
    """
    payload = {}

    temperature = parse_float_or_none(snapshot.get("OutdoorTemp"))
    humidity = parse_float_or_none(snapshot.get("OutdoorHum"))
    dew_point = parse_float_or_none(snapshot.get("OutdoorDewpoint"))
    feels_like = parse_float_or_none(snapshot.get("FeelsLike"))
    pressure_mb = parse_float_or_none(snapshot.get("PressureMB"))
    wind_now = parse_float_or_none(snapshot.get("WindLatest"))
    wind_avg = parse_float_or_none(snapshot.get("WindAverage"))
    wind_gust = parse_float_or_none(snapshot.get("Recentmaxgust"))
    wind_direction = parse_float_or_none(snapshot.get("Bearing"))
    wind_direction_avg = parse_float_or_none(snapshot.get("WindBearingAverage"))
    rain_today = snapshot.get("RainToday")
    rain_delta = get_hazcams_rain_delta(rain_today)

    if temperature is not None:
        payload["temperature"] = round(temperature, 2)
    if humidity is not None:
        payload["humidity"] = round(humidity, 2)
    if dew_point is not None:
        payload["dew_point"] = round(dew_point, 2)
    if pressure_mb is not None:
        payload["pressure"] = round(pressure_mb, 2)
    if wind_now is not None:
        payload["wind_now"] = round(wind_now, 2)
    if wind_avg is not None:
        payload["wind_avg"] = round(wind_avg, 2)
    if wind_gust is not None:
        payload["wind_gust"] = round(wind_gust, 2)
    if wind_direction is not None:
        payload["wind_direction"] = int(round(wind_direction)) % 360
    if wind_direction_avg is not None:
        payload["wind_direction_avg"] = int(round(wind_direction_avg)) % 360

    # Hazcams backend treats rain_min as incremental rainfall.
    # Do not send rain_24h here, or the backend will stack totals every second.
    payload["rain_min"] = rain_delta

    # Send heat_index/wind_chill only when applicable, matching existing parser style.
    if temperature is not None and feels_like is not None:
        if temperature <= 50 and wind_now is not None and wind_now >= 3:
            payload["wind_chill"] = round(feels_like, 2)
        elif temperature >= 80:
            payload["heat_index"] = round(feels_like, 2)

    return payload


def send_hazcams_payload(payload):
    endpoint = f"{HAZCAMS_URL}/internal/stations/{HAZCAMS_STATION_ID}/weather"
    data = json.dumps(payload).encode("utf-8")
    request = urllib.request.Request(
        endpoint,
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=HAZCAMS_TIMEOUT_SECONDS) as response:
        status = response.status
        body = response.read().decode("utf-8", errors="ignore")
    return status, body


def hazcams_thread():
    global latest_weather
    while True:
        if not HAZCAMS_ENABLED:
            latest_weather["HazcamsEnabled"] = False
            latest_weather["HazcamsLastStatus"] = "disabled"
            time.sleep(10)
            continue
        try:
            snapshot = dict(latest_weather)
            payload = build_hazcams_payload(snapshot)
            if not payload:
                latest_weather["HazcamsEnabled"] = True
                latest_weather["HazcamsLastStatus"] = "waiting for valid data"
                time.sleep(10)
                continue
            status, body = send_hazcams_payload(payload)
            now = time.localtime()
            latest_weather["HazcamsEnabled"] = True
            latest_weather["HazcamsLastSend"] = time.strftime("%I:%M:%S %p", now).lstrip("0")
            latest_weather["HazcamsLastStatus"] = f"sent HTTP {status}"
            print(f"HAZCAMS SENT | HTTP {status} | {payload}")
        except urllib.error.HTTPError as e:
            latest_weather["HazcamsEnabled"] = True
            latest_weather["HazcamsLastStatus"] = f"HTTP error {e.code}"
            print(f"HAZCAMS ERROR | HTTP {e.code} {e.reason}")
        except Exception as e:
            latest_weather["HazcamsEnabled"] = True
            latest_weather["HazcamsLastStatus"] = f"error: {e}"
            print(f"HAZCAMS ERROR | {e}")
        time.sleep(HAZCAMS_SEND_INTERVAL)



def build_wu_params(snapshot):
    """
    Build Weather Underground RapidFire parameters.
    rainin = rolling 1-hour rain.
    dailyrainin = rain since midnight.
    """
    temp_f = parse_float_or_none(snapshot.get("OutdoorTemp"))
    humidity = parse_float_or_none(snapshot.get("OutdoorHum"))
    dewpoint_f = parse_float_or_none(snapshot.get("OutdoorDewpoint"))
    pressure_in = parse_float_or_none(snapshot.get("Pressure"))
    wind_speed = parse_float_or_none(snapshot.get("WindAverage"))
    wind_gust = parse_float_or_none(snapshot.get("Recentmaxgust"))
    wind_dir = parse_float_or_none(snapshot.get("WindBearingAverage") or snapshot.get("Bearing"))
    rain_1hr = parse_float_or_none(snapshot.get("RainLastHour"))
    rain_today = parse_float_or_none(snapshot.get("RainToday"))

    if temp_f is None or humidity is None or pressure_in is None:
        return None

    params = {
        "ID": WU_STATION_ID,
        "PASSWORD": WU_STATION_KEY,
        "dateutc": "now",
        "action": "updateraw",
        "realtime": "1",
        "rtfreq": str(WU_SEND_INTERVAL),
        "tempf": f"{temp_f:.1f}",
        "humidity": str(int(round(humidity))),
        "baromin": f"{pressure_in:.2f}",
    }

    if dewpoint_f is not None:
        params["dewptf"] = f"{dewpoint_f:.1f}"

    if wind_speed is not None:
        params["windspeedmph"] = f"{wind_speed:.1f}"

    if wind_gust is not None:
        params["windgustmph"] = f"{wind_gust:.1f}"

    if wind_dir is not None:
        params["winddir"] = str(int(round(wind_dir)) % 360)

    if rain_1hr is not None:
        params["rainin"] = f"{rain_1hr:.2f}"

    if rain_today is not None:
        params["dailyrainin"] = f"{rain_today:.2f}"

    return params


def send_wu_params(params):
    query = urllib.parse.urlencode(params)
    url = f"{WU_URL}?{query}"

    request = urllib.request.Request(
        url,
        headers={"User-Agent": "PeetWxPanel/1.0"},
        method="GET",
    )

    with urllib.request.urlopen(request, timeout=WU_TIMEOUT_SECONDS) as response:
        status = response.status
        body = response.read().decode("utf-8", errors="ignore").strip()

    return status, body


def wu_thread():
    global latest_weather

    while True:
        if not WU_ENABLED:
            latest_weather["WUEnabled"] = False
            latest_weather["WULastStatus"] = "disabled"
            time.sleep(10)
            continue

        try:
            snapshot = dict(latest_weather)
            params = build_wu_params(snapshot)

            if not params:
                latest_weather["WUEnabled"] = True
                latest_weather["WULastStatus"] = "waiting for valid data"
                time.sleep(5)
                continue

            status, body = send_wu_params(params)

            now = time.localtime()
            latest_weather["WUEnabled"] = True
            latest_weather["WULastSend"] = time.strftime("%I:%M:%S %p", now).lstrip("0")
            latest_weather["WULastStatus"] = f"HTTP {status}: {body[:80]}"

            safe_params = dict(params)
            safe_params["PASSWORD"] = "***"
            print(f"WU SENT | HTTP {status} | {body[:80]} | {safe_params}")

        except urllib.error.HTTPError as e:
            latest_weather["WUEnabled"] = True
            latest_weather["WULastStatus"] = f"HTTP error {e.code}"
            print(f"WU ERROR | HTTP {e.code} {e.reason}")

        except Exception as e:
            latest_weather["WUEnabled"] = True
            latest_weather["WULastStatus"] = f"error: {e}"
            print(f"WU ERROR | {e}")

        time.sleep(WU_SEND_INTERVAL)



def reader_thread():
    global latest_weather

    print(f"Opening {PORT}...")
    ser = serial.Serial(PORT, BAUDRATE, timeout=0.05)
    print("Reading Peet line packets...")

    buffer = ""
    last_print = 0
    last_gust_save = 0
    global wind_archive_peak_minute, wind_archive_max_2min_avg, wind_archive_max_10min_gust
    last_humidity_debug = 0
    last_rain_debug = 0
    last_rain_history_save = 0

    while True:
        raw = ser.read(4096)
        if not raw:
            continue

        buffer += raw.decode("ascii", errors="ignore")

        while "\n" in buffer:
            line, buffer = buffer.split("\n", 1)
            packet = line.strip()

            if not packet:
                continue

            result = parse_packet(packet)
            if not result:
                continue

            wind, direction, temp, pressure, humidity, dewpoint, feels_like, rain_today, rain_long_term, chunks, humidity_candidates, rain_candidates = result

            now_ts = time.time()

            wind_2min_samples.append((now_ts, wind))
            gust_10min_samples.append((now_ts, wind))
            wind_dir_2min_samples.append((now_ts, direction))
            temp_2min_samples.append((now_ts, temp))

            if humidity is not None:
                humidity_2min_samples.append((now_ts, humidity))

            if dewpoint is not None:
                dewpoint_2min_samples.append((now_ts, dewpoint))

            remove_old_samples(wind_2min_samples, TWO_MINUTES, now_ts)
            remove_old_samples(gust_10min_samples, TEN_MINUTES, now_ts)
            remove_old_samples(wind_dir_2min_samples, TWO_MINUTES, now_ts)
            remove_old_samples(temp_2min_samples, TWO_MINUTES, now_ts)
            remove_old_samples(humidity_2min_samples, TWO_MINUTES, now_ts)
            remove_old_samples(dewpoint_2min_samples, TWO_MINUTES, now_ts)

            wind_avg = avg_from_samples(wind_2min_samples)
            wind_dir_avg = avg_wind_direction_from_samples(wind_dir_2min_samples)
            temp_avg = avg_from_samples(temp_2min_samples)
            humidity_avg = avg_from_samples(humidity_2min_samples)
            dewpoint_avg = avg_from_samples(dewpoint_2min_samples)

            if wind_avg is None:
                wind_avg = wind
            if wind_dir_avg is None:
                wind_dir_avg = direction
            if temp_avg is None:
                temp_avg = temp
            if humidity_avg is None:
                humidity_avg = humidity
            if dewpoint_avg is None:
                dewpoint_avg = dewpoint

            recent_gust = (
                max(sample[1] for sample in gust_10min_samples)
                if gust_10min_samples else wind
            )

            # Persist the 10-minute gust window so restarting the parser does not
            # instantly erase the recent gust display.
            if now_ts - last_gust_save > 15:
                save_gust_samples(now_ts)
                last_gust_save = now_ts

            # First calculate PEET-native rain values, then source-select them below.
            peet_rain_rate = calc_rain_rate_in_per_hr(rain_today, now_ts)
            peet_rain_24hr = update_rain_24hr_total(rain_today, now_ts)
            peet_rain_1hr = update_rain_1hr_total(rain_today, now_ts)

            if now_ts - last_rain_history_save > 60:
                save_rain_24hr_samples(now_ts)
                last_rain_history_save = now_ts

            # Per-metric source selection. PEET values use the existing smoothing above.
            # Davis values are used directly from WeatherLink Live and are NOT put through
            # this parser's 2-minute temp/humidity/dewpoint smoothing.
            temp_display, src_temp = select_metric_source("temperature", temp_avg, "temperature")
            humidity_display, src_hum = select_metric_source("humidity", humidity_avg, "humidity")
            # Apply source-specific calibration after source selection. Davis data is still
            # unsmoothed; this only calibrates the selected raw Davis reading.
            temp_display, temp_calibration = apply_metric_calibration("temperature", temp_display, src_temp)
            humidity_display, humidity_calibration = apply_metric_calibration("humidity", humidity_display, src_hum)
            # Dew point is a DERIVED metric. Calculate it from the FINAL selected and
            # calibrated temperature + humidity so it always agrees with what WxPanel
            # actually displays. This is especially important when Davis RH calibration
            # is enabled; using PEET dew point or Davis' raw dew point would bypass the
            # correction curve and produce an internally inconsistent observation.
            dewpoint_display = calc_dewpoint_f(temp_display, humidity_display) if (temp_display is not None and humidity_display is not None) else None
            src_dew = "derived"
            pressure_display, src_pressure = select_metric_source("pressure", pressure, "pressure")
            wind_display, src_wind = select_metric_source("wind_latest", wind, "wind_latest")
            wind_avg_display, src_wind_avg = select_metric_source("wind_average", wind_avg, "wind_average")
            direction_display, src_dir = select_metric_source("wind_direction", direction, "wind_direction")
            gust_display, src_gust = select_metric_source("gust", recent_gust, "gust")

            archive_minute = int(now_ts // 60)
            if wind_archive_peak_minute != archive_minute:
                wind_archive_peak_minute = archive_minute
                wind_archive_max_2min_avg = wind_avg_display
                wind_archive_max_10min_gust = gust_display
            else:
                if wind_avg_display is not None:
                    wind_archive_max_2min_avg = wind_avg_display if wind_archive_max_2min_avg is None else max(wind_archive_max_2min_avg, wind_avg_display)
                if gust_display is not None:
                    wind_archive_max_10min_gust = gust_display if wind_archive_max_10min_gust is None else max(wind_archive_max_10min_gust, gust_display)

            rain_today_display, src_rain = select_metric_source("rain", rain_today, "rain_today")
            rain_rate_display_selected, _ = select_metric_source("rain", peet_rain_rate, "rain_rate")
            rain_24hr_display, _ = select_metric_source("rain", peet_rain_24hr, "rain_24hr")
            rain_1hr_display, _ = select_metric_source("rain", peet_rain_1hr, "rain_1hr")

            # Direction averaging follows the selected wind-direction source. Davis already
            # exposes a 2-minute scalar average; PEET keeps our vector average.
            if src_dir == "davis":
                ds_values = get_davis_snapshot().get("values", {})
                wind_dir_avg_display = ds_values.get("wind_direction_average", direction_display)
            else:
                wind_dir_avg_display = wind_dir_avg

            # Feels Like is always derived from the FINAL selected temperature/humidity and
            # live wind sources, so mixed-source configurations remain internally consistent.
            feels_like_display = calc_feels_like_f(
                temp_display,
                humidity_display if humidity_display is not None else None,
                wind_display if wind_display is not None else 0,
            ) if temp_display is not None else None

            wind_extreme_candidates = [v for v in (wind_display, gust_display) if v is not None]
            wind_for_extremes = max(wind_extreme_candidates) if wind_extreme_candidates else 0

            daily_values = update_daily_extremes(
                temp_display,
                pressure_display,
                wind_for_extremes,
                direction_display,
                round(humidity_display) if humidity_display is not None else None,
                dewpoint_display,
                feels_like_display,
                rain_rate_display_selected,
                wind_avg_display,
            )

            now = time.localtime()
            davis_status = get_davis_snapshot()
            source_map = {
                "temperature": src_temp,
                "humidity": src_hum,
                "dewpoint": src_dew,
                "pressure": src_pressure,
                "wind_latest": src_wind,
                "wind_average": src_wind_avg,
                "wind_direction": src_dir,
                "gust": src_gust,
                "rain": src_rain,
            }

            latest_weather.update({
                "OutdoorTemp": f"{temp_display:.1f}" if temp_display is not None else "--.-",
                "OutdoorHum": f"{humidity_display:.0f}" if humidity_display is not None else "--",
                # V065: keep broadcast/API legacy humidity whole-number formatting,
                # but expose source-native precision for WxPanel only. WeatherLink
                # Live supplies tenths; PEET remains whole-number display.
                "WxPanelHumidity": (f"{humidity_display:.1f}" if str(src_hum).startswith("davis") else f"{humidity_display:.0f}") if humidity_display is not None else "--",
                "OutdoorDewpoint": f"{dewpoint_display:.1f}" if dewpoint_display is not None else "--.-",
                "FeelsLike": f"{feels_like_display:.1f}" if feels_like_display is not None else "--.-",
                "WindLatest": f"{wind_display:.1f}" if wind_display is not None else "--",
                "WindAverage": f"{wind_avg_display:.1f}" if wind_avg_display is not None else "--",
                "MinuteMax2MinAverageWind": wind_archive_max_2min_avg,
                "MinuteMax10MinGust": wind_archive_max_10min_gust,
                "Pressure": f"{pressure_display:.2f}" if pressure_display is not None else "--.--",
                "PressureMB": pressure_display / 0.029529983 if pressure_display is not None else None,
                "Bearing": int(direction_display) if direction_display is not None else 0,
                "WindBearingAverage": int(round(wind_dir_avg_display)) % 360 if wind_dir_avg_display is not None else 0,
                "Avgbearing": int(round(wind_dir_avg_display)) % 360 if wind_dir_avg_display is not None else 0,
                "Recentmaxgust": f"{gust_display:.1f}" if gust_display is not None else "--",
                "RainToday": f"{rain_today_display:.2f}" if rain_today_display is not None else "--.--",
                "RainRate": f"{rain_rate_display_selected:.2f}" if rain_rate_display_selected is not None else "--.--",
                "RainLast24Hours": f"{rain_24hr_display:.2f}" if rain_24hr_display is not None else "--.--",
                "RainLastHour": f"{rain_1hr_display:.2f}" if rain_1hr_display is not None else "--.--",
                "DataSources": source_map,
                "Calibration": {"temperature": temp_calibration, "humidity": humidity_calibration},
                "RawSelectedTemperature": temp_calibration.get("raw") if isinstance(temp_calibration, dict) else temp_display,
                "RawSelectedHumidity": humidity_calibration.get("raw") if isinstance(humidity_calibration, dict) else humidity_display,
                "WeatherLinkLiveEnabled": bool(WEATHER_SOURCE_CONFIG.get("weatherlink_live", {}).get("enabled", False)),
                "WeatherLinkLiveAvailable": davis_status.get("available", False),
                "WeatherLinkLiveLastSuccessEpoch": davis_status.get("last_success_epoch"),
                "WeatherLinkLiveLastError": davis_status.get("last_error"),
                "LastDataRead": time.strftime("%I:%M:%S %p", now).lstrip("0"),
                "LastDataReadDate": time.strftime("%m/%d/%Y", now),
                "LastDataReadEpoch": int(time.time()),
                "HistoricalStats": _history_stats_cache,
                **daily_values,
            })

            if HUMIDITY_DEBUG and time.time() - last_humidity_debug > 2:
                print("HUMIDITY DEBUG")
                print(f"  chunks: {chunks}")
                print(f"  candidates: {humidity_candidates}")
                print(f"  selected humidity: {latest_weather['OutdoorHum']}%")
                last_humidity_debug = time.time()

            if RAIN_DEBUG and time.time() - last_rain_debug > 2:
                print("RAIN DEBUG")
                print(f"  chunks: {chunks}")
                print(f"  candidates: {rain_candidates}")
                print(f"  selected RainToday: {latest_weather['RainToday']} in")
                print(f"  calculated RainRate: {latest_weather['RainRate']} in/hr")
                last_rain_debug = time.time()

            if time.time() - last_print > 1:
                print(
                    f"T {latest_weather['OutdoorTemp']} ({source_map['temperature']}) | "
                    f"H {latest_weather['OutdoorHum']}% | "
                    f"DP {latest_weather['OutdoorDewpoint']} | "
                    f"FL {latest_weather['FeelsLike']} | "
                    f"W {wind:.1f} | "
                    f"2min Avg {wind_avg:.1f} @ {int(round(wind_dir_avg)) % 360}° | "
                    f"10min Gust {recent_gust:.1f} | "
                    f"P {pressure:.2f} | "
                    f"Rain {latest_weather['RainToday']} | "
                    f"24hr {latest_weather['RainLast24Hours']} | "
                    f"Rate {latest_weather['RainRate']}"
                )
                last_print = time.time()


@app.route("/weather_source_manager.html")
def weather_source_manager():
    return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "weather_source_manager.html")


@app.route("/api/weather-sources", methods=["GET", "POST"])
def weather_sources_api():
    global WEATHER_SOURCE_CONFIG
    if request.method == "GET":
        status = get_davis_snapshot()
        return jsonify({
            "success": True,
            "config": WEATHER_SOURCE_CONFIG,
            "weatherlink_live": {
                "available": status.get("available", False),
                "last_success_epoch": status.get("last_success_epoch"),
                "last_error": status.get("last_error"),
                "values": status.get("values", {}),
            }
        })

    incoming = request.get_json(silent=True) or {}
    merged = json.loads(json.dumps(DEFAULT_SOURCE_CONFIG))
    current = WEATHER_SOURCE_CONFIG if isinstance(WEATHER_SOURCE_CONFIG, dict) else {}
    merged.update({k: v for k, v in current.items() if k not in ("weatherlink_live", "sources", "calibration")})
    if isinstance(current.get("weatherlink_live"), dict):
        merged["weatherlink_live"].update(current["weatherlink_live"])
    if isinstance(current.get("sources"), dict):
        merged["sources"].update(current["sources"])
    if isinstance(current.get("calibration"), dict):
        for source in ("davis", "peet"):
            if isinstance(current["calibration"].get(source), dict):
                for metric in ("temperature", "humidity"):
                    if isinstance(current["calibration"][source].get(metric), dict):
                        merged["calibration"][source][metric].update(current["calibration"][source][metric])

    if isinstance(incoming.get("weatherlink_live"), dict):
        for key in ("enabled", "host", "port", "txid", "poll_seconds", "timeout_seconds"):
            if key in incoming["weatherlink_live"]:
                merged["weatherlink_live"][key] = incoming["weatherlink_live"][key]
    if "fallback_to_peet" in incoming:
        merged["fallback_to_peet"] = bool(incoming["fallback_to_peet"])
    if isinstance(incoming.get("sources"), dict):
        for metric, source in incoming["sources"].items():
            if metric in merged["sources"] and str(source).lower() in ("peet", "davis"):
                merged["sources"][metric] = str(source).lower()
    if isinstance(incoming.get("calibration"), dict):
        for source in ("davis", "peet"):
            src_cfg = incoming["calibration"].get(source)
            if not isinstance(src_cfg, dict):
                continue
            for metric in ("temperature", "humidity"):
                mc = src_cfg.get(metric)
                if not isinstance(mc, dict):
                    continue
                dest = merged["calibration"][source][metric]
                if "enabled" in mc:
                    dest["enabled"] = bool(mc["enabled"])
                for key in ("offset", "multiplier", "multiplier2"):
                    if key in mc:
                        n = _finite_number(mc[key])
                        if n is not None:
                            dest[key] = n

    merged.setdefault("sources", {})["dewpoint"] = "derived"
    WEATHER_SOURCE_CONFIG = merged
    save_weather_source_config(merged)
    return jsonify({"success": True, "config": merged, "note": "Source choices apply immediately. Restart parser only if WeatherLink Live host/port/txid settings changed."})


@app.route("/api/data/currentdata")
def current_data():
    return jsonify(latest_weather)


if __name__ == "__main__":
    wll = threading.Thread(target=weatherlink_live_thread, daemon=True)
    wll.start()

    t = threading.Thread(target=reader_thread, daemon=True)
    t.start()

    hist = threading.Thread(target=history_thread, daemon=True)
    hist.start()

    cwop = threading.Thread(target=cwop_thread, daemon=True)
    cwop.start()

    hazcams = threading.Thread(target=hazcams_thread, daemon=True)
    hazcams.start()

    wu = threading.Thread(target=wu_thread, daemon=True)
    wu.start()

    app.run(host="0.0.0.0", port=int(os.environ.get("NWALS_PEET_PORT", "5000")), threaded=True)
