## -*- coding: utf-8 -*-

import tkinter as tk
import threading
import asyncio
import websockets
import json
import queue
import time
import os
import requests

# -------------------------
# Notificaciones PUSHOVER
# -------------------------
PUSHOVER_USER = "uyzqhff9yzty5ngrzprznsc2nvysft"
PUSHOVER_TOKEN = "anhyrhsp5owy7z78r8qhi5xmrsxh4y"

def enviar_notificacion(mensaje):
    url = "https://api.pushover.net/1/messages.json"
    data = {
        "token": PUSHOVER_TOKEN,
        "user": PUSHOVER_USER,
        "message": mensaje,
        "title": "InverTrack",
        "priority": 1
    }
    try:
        requests.post(url, data=data, timeout=3)
    except Exception as e:
        print("Error enviando notificación:", e)

# Configuración general
WS_URI = "ws://localhost:8764"
POLL_MS = 200
CONFIG_FILE = "config.json"

BG = "#ffffff"
ACCENT = "#4caf50"
ACCENT_DARK = "#43a047"
TEXT = "#222222"
SUBTEXT = "#555555"
CARD_BG = "#f7f7f7"
BORDER = "#dedede"

# Manejo de alertas / anti-spam
ALERTA_COOLDOWN = 60 
# -------------------------
# LÍMITES PREDEFINIDOS (ARICA)
# -------------------------
CULTIVOS_PREDEFINIDOS = {
    "Tomate": {
        "ph": {"min": 5.5, "max": 6.8},
        "temp": {"amb_min": 18, "amb_max": 30},
        "hum": {"amb_min": 60, "amb_max": 80},
        "npk": {
            "n_min": 50, "n_max": 150,
            "p_min": 30, "p_max": 80,
            "k_min": 150, "k_max": 300
        }
    },
    "Lechuga": {
        "ph": {"min": 5.8, "max": 6.5},
        "temp": {"amb_min": 10, "amb_max": 22},
        "hum": {"amb_min": 60, "amb_max": 90},
        "npk": {
            "n_min": 40, "n_max": 120,
            "p_min": 20, "p_max": 60,
            "k_min": 80, "k_max": 200
        }
    },
    "Cítricos": {
        "ph": {"min": 5.5, "max": 6.5},
        "temp": {"amb_min": 20, "amb_max": 35},
        "hum": {"amb_min": 50, "amb_max": 70},
        "npk": {
            "n_min": 60, "n_max": 180,
            "p_min": 30, "p_max": 90,
            "k_min": 100, "k_max": 250
        }
    },
    "Maíz": {
        "ph": {"min": 5.5, "max": 7.0},
        "temp": {"amb_min": 18, "amb_max": 32},
        "hum": {"amb_min": 50, "amb_max": 75},
        "npk": {
            "n_min": 80, "n_max": 200,
            "p_min": 40, "p_max": 100,
            "k_min": 100, "k_max": 300
        }
    }
}


class AlertaManager:
    def __init__(self, cooldown=ALERTA_COOLDOWN):
        self.cooldown = cooldown
        self.ultimo = {}  

    def debe_alertar(self, clave):
        ahora = time.time()
        last = self.ultimo.get(clave, 0)
        if ahora - last >= self.cooldown:
            self.ultimo[clave] = ahora
            return True
        return False
    
# Utilidades UI
def make_label(master, text, size=12, bold=False, fg=TEXT):
    font = ("Segoe UI", size, "bold") if bold else ("Segoe UI", size)
    return tk.Label(master, text=text, font=font, bg=master["bg"], fg=fg)

def style_button_primary(btn):
    btn.config(bg=ACCENT, fg="white", activebackground=ACCENT_DARK,
               relief="flat", bd=0, padx=10, pady=6,
               font=("Segoe UI", 10, "bold"), cursor="hand2")

def style_button_secondary(btn):
    btn.config(bg="#e0e0e0", fg=TEXT, relief="flat", bd=0,
               padx=8, pady=6, font=("Segoe UI", 10), cursor="hand2")

# ScrollFrame
class ScrollFrame(tk.Frame):
    def __init__(self, parent, *args, **kwargs):
        bg = kwargs.pop("bg", BG)
        super().__init__(parent, bg=bg, *args, **kwargs)

        self.canvas = tk.Canvas(self, bg=bg, highlightthickness=0)
        self.vsb = tk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
        self.canvas.configure(yscrollcommand=self.vsb.set)

        self.inner = tk.Frame(self.canvas, bg=bg)
        self.window = self.canvas.create_window((0, 0), window=self.inner, anchor="nw")

        self.canvas.pack(side="left", fill="both", expand=True)
        self.vsb.pack(side="right", fill="y")

        self.inner.bind("<Configure>", self._on_frame_configure)
        self.canvas.bind("<Configure>", self._on_canvas_configure)
        self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)

    def _on_frame_configure(self, event=None):
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))

    def _on_canvas_configure(self, event):
        self.canvas.itemconfig(self.window, width=event.width)

    def _on_mousewheel(self, event):
        try:
            self.canvas.yview_scroll(int(-1*(event.delta/120)), "units")
        except:
            pass

# HorizontalCard
class HorizontalCard(tk.Frame):
    def __init__(self, parent, title):
        super().__init__(parent, bg=CARD_BG, highlightthickness=1,
                         highlightbackground=BORDER, padx=12, pady=10)

        self.lbl_title = make_label(self, title, size=11, bold=True)
        self.lbl_title.pack(anchor="w")

        self.lbl_value = make_label(self, "--", size=14, bold=True, fg=SUBTEXT)
        self.lbl_value.pack(anchor="w", pady=(6,0))

    def set_value(self, text):
        self.lbl_value.config(text=text)

class DualHorizontalCard(tk.Frame):
    def __init__(self, parent, left_title, right_title):
        super().__init__(
            parent,
            bg=CARD_BG,
            highlightthickness=1,
            highlightbackground=BORDER,
            padx=12,
            pady=10
        )

        cont = tk.Frame(self, bg=CARD_BG)
        cont.pack(fill="x")

        # --- IZQUIERDA (Ambiente) ---
        left = tk.Frame(cont, bg=CARD_BG)
        left.pack(side="left", expand=True, fill="x")

        self.lbl_left_title = make_label(left, left_title, size=10, bold=True)
        self.lbl_left_title.pack(anchor="center")

        self.lbl_left_value = make_label(left, "--", size=16, bold=True)
        self.lbl_left_value.pack(anchor="center", pady=(6, 0))

        # --- SEPARADOR ---
        sep = tk.Frame(cont, width=1, bg=BORDER)
        sep.pack(side="left", fill="y", padx=8)

        # --- DERECHA (Suelo) ---
        right = tk.Frame(cont, bg=CARD_BG)
        right.pack(side="left", expand=True, fill="x")

        self.lbl_right_title = make_label(right, right_title, size=10, bold=True)
        self.lbl_right_title.pack(anchor="center")

        self.lbl_right_value = make_label(right, "--", size=16, bold=True)
        self.lbl_right_value.pack(anchor="center", pady=(6, 0))

    def set_left(self, text):
        self.lbl_left_value.config(text=text)

    def set_right(self, text):
        self.lbl_right_value.config(text=text)

    def set_value(self, text):
        # Compatibilidad con código antiguo
        self.set_left(text)

# AccordionSection
class AccordionSection(tk.Frame):
    def __init__(self, parent, app, key, title, config_schema):
        super().__init__(parent, bg=BG)
        self.app = app
        self.key = key
        self.title = title
        self.config_schema = config_schema
        self.config_values = {}
        self.is_open = False

        self.header = tk.Frame(self, bg=BG)
        self.header.pack(fill="x", pady=(6,0))

        self.arrow = make_label(self.header, "►", size=14)
        self.arrow.pack(side="left", padx=(4,8))

        self.lbl_title = make_label(self.header, title, size=13, bold=True)
        self.lbl_title.pack(side="left", pady=8)

        self.header.bind("<Button-1>", self.toggle)
        self.arrow.bind("<Button-1>", self.toggle)
        self.lbl_title.bind("<Button-1>", self.toggle)

        self.body = tk.Frame(self, bg=BG)

        if key in ("temp", "hum"):
            self.card = DualHorizontalCard(
                self.body,
                "Ambiente",
                "Suelo"
            )
        else:
            self.card = HorizontalCard(self.body, title)

        self.card.pack(fill="x", pady=(10,6))
        self.card.pack(fill="x", pady=(10,6))

        self.actions_frame = tk.Frame(self.body, bg=BG)
        self.actions_frame.pack(fill="x", pady=(0,6))

        if key == "ph":
            self.btn_action = tk.Button(self.actions_frame, text="Activar Dosificador")
            style_button_primary(self.btn_action)
            self.btn_action.pack(side="left", padx=4)
        elif key == "temp":
            self.btn_action = tk.Button(self.actions_frame, text="Desplegar Mallas")
            style_button_primary(self.btn_action)
            self.btn_action.pack(side="left", padx=4)
        else:
            self.btn_action = None

        self.btn_config = tk.Button(self.actions_frame, text="Configurar")
        style_button_secondary(self.btn_config)
        self.btn_config.pack(side="left", padx=4)
        self.btn_config.config(command=self.toggle_config_panel)

        self.config_panel = tk.Frame(self.body, bg=BG,
                                     highlightthickness=1,
                                     highlightbackground=BORDER)

        self._build_config_fields()

        stored = self.app.config_store.get(self.key, {})
        for f in self.config_schema:
            name = f["name"]
            default = f.get("default", "")
            val = stored.get(name, default)
            self.config_values[name] = val
            if name in self.field_vars:
                self.field_vars[name][0].set(str(val))

    def _build_config_fields(self):
       hdr = make_label(self.config_panel, "Configuración", size=12, bold=True)
       hdr.pack(anchor="w", padx=10, pady=(8,6))

       self.field_vars = {}
       self.error_labels = {}

       for field in self.config_schema:
           frm = tk.Frame(self.config_panel, bg=BG)
           frm.pack(fill="x", padx=10, pady=2)

           lbl = make_label(frm, field.get("label", field["name"]), size=10)
           lbl.pack(anchor="w")

           var = tk.StringVar(value=str(field.get("default", "")))
           ent = tk.Entry(frm, textvariable=var)
           ent.pack(fill="x")

           err = tk.Label(frm, text="", fg="red", bg=BG, font=("Segoe UI", 9))
           err.pack(anchor="w")

           self.field_vars[field["name"]] = (var, field)
           self.error_labels[field["name"]] = err

       btns = tk.Frame(self.config_panel, bg=BG)
       btns.pack(fill="x", padx=10, pady=(6,10))

       btn_save = tk.Button(btns, text="Guardar", width=10)
       style_button_primary(btn_save)
       btn_save.pack(side="right", padx=6)
       btn_save.config(command=self._on_save_config)

       btn_cancel = tk.Button(btns, text="Cerrar", width=10, bg="#e0e0e0", relief="flat")
       btn_cancel.pack(side="right")
       btn_cancel.config(command=self.toggle_config_panel)

    def toggle(self, event=None):
        if self.is_open:
            self.body.pack_forget()
            self.arrow.config(text="►")
            self.is_open = False
        else:
            self.body.pack(fill="x")
            self.arrow.config(text="▼")
            self.is_open = True

    def toggle_config_panel(self):
        if self.config_panel.winfo_ismapped():
            self.config_panel.pack_forget()
        else:
            self.config_panel.pack(fill="x", padx=4, pady=(6,8))

    def set_value(self, text):
        self.card.set_value(text)

    def _on_save_config(self):
        cfg = {}

        for lbl in self.error_labels.values():
            lbl.config(text="")

        for name, (var, meta) in self.field_vars.items():
            cfg[name] = var.get().strip()

        errores = self.validar_config(cfg)

        if errores:
            for campo, msg in errores.items():
                if campo in self.error_labels:
                    self.error_labels[campo].config(text=msg)
            return

        for name, (var, meta) in self.field_vars.items():
            raw = var.get().strip()
            t = meta.get("type", "str")
            try:
                if t == "int":
                    cfg[name] = int(raw)
                elif t == "float":
                    cfg[name] = float(raw)
                else:
                    cfg[name] = raw
            except:
                cfg[name] = None

        self.app.config_store.setdefault(self.key, {}).update(cfg)
        self.app.save_config_file()

        payload = {
            "type": "config",
            "sensor": self.key,
            "config": cfg,
            "ts": time.time()
        }

        try:
            self.app.outgoing_q.put_nowait(payload)
        except:
            pass

        self.toggle_config_panel()

    def actualizar_campos(self, nuevos_valores):
        for nombre, valor in nuevos_valores.items():
            if nombre in self.field_vars:
                self.field_vars[nombre][0].set(str(valor))


    def validar_config(self, cfg):
        errores = {}

        def num(x):
            try:
                return float(x)
            except:
                return None

        if self.key == "ph":
            lo = num(cfg.get("min"))
            hi = num(cfg.get("max"))
            if lo is None or hi is None:
                errores["min"] = "Debe ser numérico"
                errores["max"] = "Debe ser numérico"
            elif lo >= hi:
                errores["min"] = "Debe ser menor que el máximo"
                errores["max"] = "Debe ser mayor que el mínimo"

        if self.key == "temp":
            lo = num(cfg.get("amb_min"))
            hi = num(cfg.get("amb_max"))
            if lo is None or hi is None:
                errores["amb_min"] = "Debe ser numérico"
                errores["amb_max"] = "Debe ser numérico"
            elif lo >= hi:
                errores["amb_min"] = "Debe ser menor que el máximo"
                errores["amb_max"] = "Debe ser mayor que el mínimo"

        if self.key == "hum":
            lo = num(cfg.get("amb_min"))
            hi = num(cfg.get("amb_max"))
            if lo is None or hi is None:
                errores["amb_min"] = "Debe ser numérico"
                errores["amb_max"] = "Debe ser numérico"
            elif lo < 0 or hi > 100:
                errores["amb_min"] = "Rango válido 0–100"
                errores["amb_max"] = "Rango válido 0–100"
            elif lo >= hi:
                errores["amb_min"] = "Debe ser menor que el máximo"
                errores["amb_max"] = "Debe ser mayor que el mínimo"

        if self.key == "npk":
            for nutr in ("n", "p", "k"):
                lo = num(cfg.get(f"{nutr}_min"))
                hi = num(cfg.get(f"{nutr}_max"))
                if lo is None or hi is None:
                    errores[f"{nutr}_min"] = "Debe ser numérico"
                    errores[f"{nutr}_max"] = "Debe ser numérico"
                elif lo < 0 or hi < 0:
                    errores[f"{nutr}_min"] = "No puede ser negativo"
                    errores[f"{nutr}_max"] = "No puede ser negativo"
                elif lo >= hi:
                    errores[f"{nutr}_min"] = "Debe ser menor que el máximo"
                    errores[f"{nutr}_max"] = "Debe ser mayor que el mínimo"

        return errores


# MAIN APP
class MainApp(tk.Tk):
    def __init__(self, incoming_q, outgoing_q):
        super().__init__()
        self.title("InverTrack")
        self.configure(bg=BG)
        self.geometry("520x700")
        self.resizable(True, True)

        self.incoming_q = incoming_q
        self.outgoing_q = outgoing_q
        self.config_store = {}

        self._load_config_file()

        self.alertas = AlertaManager()

        hdr = tk.Frame(self, bg=BG)
        hdr.pack(fill="x", pady=(12,4))
        make_label(hdr, "InverTrack", size=20, bold=True).pack()
        make_label(hdr, "Panel de monitoreo", size=11, fg=SUBTEXT).pack()

        sf = ScrollFrame(self, bg=BG)
        sf.pack(fill="both", expand=True, padx=12, pady=12)
        container = sf.inner

        # MENÚ LÍMITES PREDEFINIDOS


        ph_schema = [
            {"name":"min", "label":"Mínimo (pH)", "type":"float", "default":"5.5"},
            {"name":"max", "label":"Máximo (pH)", "type":"float", "default":"7.0"},
        ]
        npk_schema = [
            {"name":"n_min","label":"N min","type":"float","default":"0"},
            {"name":"n_max","label":"N max","type":"float","default":"100"},
            {"name":"p_min","label":"P min","type":"float","default":"0"},
            {"name":"p_max","label":"P max","type":"float","default":"100"},
            {"name":"k_min","label":"K min","type":"float","default":"0"},
            {"name":"k_max","label":"K max","type":"float","default":"100"},
        ]
        temp_schema = [
            {"name":"amb_min","label":"Ambiente min (°C)","type":"float","default":"12"},
            {"name":"amb_max","label":"Ambiente max (°C)","type":"float","default":"35"},
        ]
        hum_schema = [
            {"name":"amb_min","label":"Ambiente min (%)","type":"float","default":"30"},
            {"name":"amb_max","label":"Ambiente max (%)","type":"float","default":"80"},
        ]

        self.sections = {}
        self.sections["ph"] = AccordionSection(container, self, "ph", "pH del agua", ph_schema)
        self.sections["ph"].pack(fill="x", pady=8)

        self.sections["npk"] = AccordionSection(container, self, "npk", "NPK (N-P-K)", npk_schema)
        self.sections["npk"].pack(fill="x", pady=8)

        self.sections["temp"] = AccordionSection(container, self, "temp", "Temperaturas", temp_schema)
        self.sections["temp"].pack(fill="x", pady=8)

        self.sections["hum"] = AccordionSection(container, self, "hum", "Humedad", hum_schema)
        self.sections["hum"].pack(fill="x", pady=8)

        self.presets_section = AccordionSection(
            container,
            self,
            "presets",
            "Límites Predefinidos",
            []
        )
        self.presets_section.pack(fill="x", pady=8)

        for cultivo in CULTIVOS_PREDEFINIDOS:
            fila = tk.Frame(self.presets_section.body, bg=BG)
            fila.pack(fill="x", padx=12, pady=6)

            make_label(fila, cultivo, size=11).pack(side="left")

            btn = tk.Button(
                fila,
                text="Asignar",
                command=lambda c=cultivo: self.aplicar_cultivo(c)
            )
            style_button_primary(btn)
            btn.pack(side="right")

        # Mantener cerrado por defecto
        self.presets_section.body.pack_forget()

        if hasattr(self.presets_section, "btn_config"):
           self.presets_section.btn_config.pack_forget()
        
        if self.presets_section.key == "presets":
            # Quitar tarjeta de valor (--)
            if hasattr(self.presets_section, "card"):
                self.presets_section.card.pack_forget()

            # Quitar frame de acciones (configurar / automatizaciones)
            if hasattr(self.presets_section, "actions_frame"):
                self.presets_section.actions_frame.pack_forget()

        self.presets_section.body.pack_forget()

        btn_exit = tk.Button(self, text="Salir", bg="#e0e0e0", relief="flat", command=self.on_close)
        btn_exit.pack(side="bottom", fill="x", padx=8, pady=8)

        self.after(POLL_MS, self._poll_incoming)

    def _load_config_file(self):
        try:
            if os.path.exists(CONFIG_FILE):
                with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                    self.config_store = json.load(f)
            else:
                self.config_store = {}
        except:
            self.config_store = {}

    def save_config_file(self):
        try:
            with open(CONFIG_FILE + ".tmp", "w", encoding="utf-8") as f:
                json.dump(self.config_store, f, indent=2, ensure_ascii=False)
            os.replace(CONFIG_FILE + ".tmp", CONFIG_FILE)
        except Exception as e:
            print("Error guardando config:", e)
    
    def aplicar_cultivo(self, nombre):
        if nombre not in CULTIVOS_PREDEFINIDOS:
            return

        datos = CULTIVOS_PREDEFINIDOS[nombre]

        # Aplicar los nuevos valores a config_store
        for sensor, cfg in datos.items():
            self.config_store.setdefault(sensor, {}).update(cfg)

            if sensor in self.sections:
                # Actualizamos los valores en la UI usando actualizar_campos
                self.sections[sensor].actualizar_campos(cfg)

            payload = {
                "type": "config",
                "sensor": sensor,
                "config": cfg,
                "ts": time.time()
            }

            try:
                self.outgoing_q.put_nowait(payload)
            except:
                pass

        # Guardar configuración en el archivo local
        self.save_config_file()

        # Notificar que se aplicó el cultivo
        enviar_notificacion(f"Límites predefinidos aplicados: {nombre}")
            

    def _poll_incoming(self):
        try:
            while True:
                item = self.incoming_q.get_nowait()

                if "temp" in item and item.get("temp") is not None:
                    t_raw = item.get("temp")
                    try:
                        t = float(t_raw)
                    except:
                        t = None

                    if t is not None:
                        self.sections["temp"].set_value(f"Ambiente: {t} °C")

                        cfg = self.config_store.get("temp", {})
                        amb_min = cfg.get("amb_min")
                        amb_max = cfg.get("amb_max")

                        try:
                            amb_min = float(amb_min) if amb_min is not None else None
                        except:
                            amb_min = None
                        try:
                            amb_max = float(amb_max) if amb_max is not None else None
                        except:
                            amb_max = None

                        fuera = False
                        if amb_min is not None and t < amb_min:
                            fuera = True
                        if amb_max is not None and t > amb_max:
                            fuera = True

                        if fuera and self.alertas.debe_alertar("temp_amb"):
                            enviar_notificacion(f"Temperatura ambiente fuera de rango: {t} °C")

                if "hum" in item and item.get("hum") is not None:
                    h_raw = item.get("hum")
                    try:
                        h = float(h_raw)
                    except:
                        h = None

                    if h is not None:
                        self.sections["hum"].set_value(f"Ambiente: {h}%")

                        cfg = self.config_store.get("hum", {})
                        amb_min = cfg.get("amb_min")
                        amb_max = cfg.get("amb_max")

                        try:
                            amb_min = float(amb_min) if amb_min is not None else None
                        except:
                            amb_min = None
                        try:
                            amb_max = float(amb_max) if amb_max is not None else None
                        except:
                            amb_max = None

                        fuera = False
                        if amb_min is not None and h < amb_min:
                            fuera = True
                        if amb_max is not None and h > amb_max:
                            fuera = True

                        if fuera and self.alertas.debe_alertar("hum_amb"):
                            enviar_notificacion(f"Humedad ambiente fuera de rango: {h}%")

                if "valor_adc" in item and item.get("valor_adc") is not None:
                    try:
                        ph = float(item.get("valor_adc"))
                    except:
                        ph = None

                    if ph is not None:
                        self.sections["ph"].set_value(f"{ph} pH")

                        cfg = self.config_store.get("ph", {})
                        try:
                            lo = float(cfg.get("min")) if cfg.get("min") is not None else None
                        except:
                            lo = None
                        try:
                            hi = float(cfg.get("max")) if cfg.get("max") is not None else None
                        except:
                            hi = None

                        fuera = False
                        if lo is not None and ph < lo:
                            fuera = True
                        if hi is not None and ph > hi:
                            fuera = True

                        if fuera and self.alertas.debe_alertar("ph"):
                            enviar_notificacion(f"pH fuera de rango: {ph}")

                if any(k in item for k in ("n", "p", "k")):
                    n_raw = item.get("n")
                    p_raw = item.get("p")
                    k_raw = item.get("k")

                    n_disp = n_raw if n_raw is not None else "--"
                    p_disp = p_raw if p_raw is not None else "--"
                    k_disp = k_raw if k_raw is not None else "--"
                    self.sections["npk"].set_value(f"N: {n_disp}   P: {p_disp}   K: {k_disp}")

                    cfg = self.config_store.get("npk", {})

                    try:
                        n_val = float(n_raw)
                        n_min = float(cfg.get("n_min")) if cfg.get("n_min") is not None else None
                        n_max = float(cfg.get("n_max")) if cfg.get("n_max") is not None else None
                    except:
                        n_val = None
                        n_min = None
                        n_max = None

                    if n_val is not None:
                        fuera = False
                        if n_min is not None and n_val < n_min:
                            fuera = True
                        if n_max is not None and n_val > n_max:
                            fuera = True
                        if fuera and self.alertas.debe_alertar("npk_n"):
                            enviar_notificacion(f"N fuera de rango: {n_val}")

                    try:
                        p_val = float(p_raw)
                        p_min = float(cfg.get("p_min")) if cfg.get("p_min") is not None else None
                        p_max = float(cfg.get("p_max")) if cfg.get("p_max") is not None else None
                    except:
                        p_val = None
                        p_min = None
                        p_max = None

                    if p_val is not None:
                        fuera = False
                        if p_min is not None and p_val < p_min:
                            fuera = True
                        if p_max is not None and p_val > p_max:
                            fuera = True
                        if fuera and self.alertas.debe_alertar("npk_p"):
                            enviar_notificacion(f"P fuera de rango: {p_val}")

                    try:
                        k_val = float(k_raw)
                        k_min = float(cfg.get("k_min")) if cfg.get("k_min") is not None else None
                        k_max = float(cfg.get("k_max")) if cfg.get("k_max") is not None else None
                    except:
                        k_val = None
                        k_min = None
                        k_max = None

                    if k_val is not None:
                        fuera = False
                        if k_min is not None and k_val < k_min:
                            fuera = True
                        if k_max is not None and k_val > k_max:
                            fuera = True
                        if fuera and self.alertas.debe_alertar("npk_k"):
                            enviar_notificacion(f"K fuera de rango: {k_val}")

        except queue.Empty:
            pass

        self.after(POLL_MS, self._poll_incoming)

    def on_close(self):
        self.destroy()

# SensorClient
class SensorClient:
    def __init__(self, incoming_q, outgoing_q, uri=WS_URI):
        self.incoming_q = incoming_q
        self.outgoing_q = outgoing_q
        self.uri = uri
        self.thread = None
        self._stop = threading.Event()

    def start(self):
        if self.thread and self.thread.is_alive():
            return
        self.thread = threading.Thread(target=self._run, daemon=True)
        self.thread.start()

    def stop(self):
        self._stop.set()

    def _run(self):
        asyncio.run(self._main())

    async def _main(self):
        backoff = 1
        while not self._stop.is_set():
            try:
                async with websockets.connect(self.uri) as ws:
                    print("SensorClient: conectado a", self.uri)

                    # NOTIFICACIÓN: CONECTADO
                    enviar_notificacion("Raspberry conectada al servidor de sensores.")

                    sender_task = asyncio.create_task(self._sender(ws))
                    try:
                        async for msg in ws:
                            try:
                                data = json.loads(msg)
                            except:
                                continue

                            normalized = {
                                "temp": data.get("temp_ambiente", data.get("temp")),
                                "hum": data.get("hum_ambiente", data.get("hum")),
                                "valor_adc": data.get("valor_adc", data.get("valor ADC")),
                                "n": data.get("n"),
                                "p": data.get("p"),
                                "k": data.get("k"),
                            }

                            try:
                                self.incoming_q.put_nowait(normalized)
                            except queue.Full:
                                pass

                    finally:
                        sender_task.cancel()

            except Exception as e:
                print("SensorClient: error conexión ->", e)

                # NOTIFICACIÓN: DESCONECTADO
                enviar_notificacion("Raspberry desconectada del servidor de sensores.")

                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 30)

    async def _sender(self, ws):
        loop = asyncio.get_event_loop()
        while True:
            try:
                payload = await loop.run_in_executor(None, self.outgoing_q.get)
                try:
                    await ws.send(json.dumps(payload))
                except Exception as exc:
                    print("SensorClient: error enviando payload:", exc)
            except Exception:
                await asyncio.sleep(0.1)

# Boot
def main():
    incoming_q = queue.Queue(maxsize=500)
    outgoing_q = queue.Queue(maxsize=200)

    client = SensorClient(incoming_q, outgoing_q, uri=WS_URI)
    client.start()

    app = MainApp(incoming_q, outgoing_q)
    try:
        app.mainloop()
    finally:
        client.stop()

if __name__ == "__main__":
    main()
