Project

General

Profile

Interfaz.py

Código correspondiente a la interfaz. - Kary Tudela, 12/24/2025 06:25 PM

Download (31.1 KB)

 
1
## -*- coding: utf-8 -*-
2

    
3
import tkinter as tk
4
import threading
5
import asyncio
6
import websockets
7
import json
8
import queue
9
import time
10
import os
11
import requests
12

    
13
# -------------------------
14
# Notificaciones PUSHOVER
15
# -------------------------
16
PUSHOVER_USER = "uyzqhff9yzty5ngrzprznsc2nvysft"
17
PUSHOVER_TOKEN = "anhyrhsp5owy7z78r8qhi5xmrsxh4y"
18

    
19
def enviar_notificacion(mensaje):
20
    url = "https://api.pushover.net/1/messages.json"
21
    data = {
22
        "token": PUSHOVER_TOKEN,
23
        "user": PUSHOVER_USER,
24
        "message": mensaje,
25
        "title": "InverTrack",
26
        "priority": 1
27
    }
28
    try:
29
        requests.post(url, data=data, timeout=3)
30
    except Exception as e:
31
        print("Error enviando notificación:", e)
32

    
33
# Configuración general
34
WS_URI = "ws://localhost:8764"
35
POLL_MS = 200
36
CONFIG_FILE = "config.json"
37

    
38
BG = "#ffffff"
39
ACCENT = "#4caf50"
40
ACCENT_DARK = "#43a047"
41
TEXT = "#222222"
42
SUBTEXT = "#555555"
43
CARD_BG = "#f7f7f7"
44
BORDER = "#dedede"
45

    
46
# Manejo de alertas / anti-spam
47
ALERTA_COOLDOWN = 60 
48
# -------------------------
49
# LÍMITES PREDEFINIDOS (ARICA)
50
# -------------------------
51
CULTIVOS_PREDEFINIDOS = {
52
    "Tomate": {
53
        "ph": {"min": 5.5, "max": 6.8},
54
        "temp": {"amb_min": 18, "amb_max": 30},
55
        "hum": {"amb_min": 60, "amb_max": 80},
56
        "npk": {
57
            "n_min": 50, "n_max": 150,
58
            "p_min": 30, "p_max": 80,
59
            "k_min": 150, "k_max": 300
60
        }
61
    },
62
    "Lechuga": {
63
        "ph": {"min": 5.8, "max": 6.5},
64
        "temp": {"amb_min": 10, "amb_max": 22},
65
        "hum": {"amb_min": 60, "amb_max": 90},
66
        "npk": {
67
            "n_min": 40, "n_max": 120,
68
            "p_min": 20, "p_max": 60,
69
            "k_min": 80, "k_max": 200
70
        }
71
    },
72
    "Cítricos": {
73
        "ph": {"min": 5.5, "max": 6.5},
74
        "temp": {"amb_min": 20, "amb_max": 35},
75
        "hum": {"amb_min": 50, "amb_max": 70},
76
        "npk": {
77
            "n_min": 60, "n_max": 180,
78
            "p_min": 30, "p_max": 90,
79
            "k_min": 100, "k_max": 250
80
        }
81
    },
82
    "Maíz": {
83
        "ph": {"min": 5.5, "max": 7.0},
84
        "temp": {"amb_min": 18, "amb_max": 32},
85
        "hum": {"amb_min": 50, "amb_max": 75},
86
        "npk": {
87
            "n_min": 80, "n_max": 200,
88
            "p_min": 40, "p_max": 100,
89
            "k_min": 100, "k_max": 300
90
        }
91
    }
92
}
93

    
94

    
95
class AlertaManager:
96
    def __init__(self, cooldown=ALERTA_COOLDOWN):
97
        self.cooldown = cooldown
98
        self.ultimo = {}  
99

    
100
    def debe_alertar(self, clave):
101
        ahora = time.time()
102
        last = self.ultimo.get(clave, 0)
103
        if ahora - last >= self.cooldown:
104
            self.ultimo[clave] = ahora
105
            return True
106
        return False
107
    
108
# Utilidades UI
109
def make_label(master, text, size=12, bold=False, fg=TEXT):
110
    font = ("Segoe UI", size, "bold") if bold else ("Segoe UI", size)
111
    return tk.Label(master, text=text, font=font, bg=master["bg"], fg=fg)
112

    
113
def style_button_primary(btn):
114
    btn.config(bg=ACCENT, fg="white", activebackground=ACCENT_DARK,
115
               relief="flat", bd=0, padx=10, pady=6,
116
               font=("Segoe UI", 10, "bold"), cursor="hand2")
117

    
118
def style_button_secondary(btn):
119
    btn.config(bg="#e0e0e0", fg=TEXT, relief="flat", bd=0,
120
               padx=8, pady=6, font=("Segoe UI", 10), cursor="hand2")
121

    
122
# ScrollFrame
123
class ScrollFrame(tk.Frame):
124
    def __init__(self, parent, *args, **kwargs):
125
        bg = kwargs.pop("bg", BG)
126
        super().__init__(parent, bg=bg, *args, **kwargs)
127

    
128
        self.canvas = tk.Canvas(self, bg=bg, highlightthickness=0)
129
        self.vsb = tk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
130
        self.canvas.configure(yscrollcommand=self.vsb.set)
131

    
132
        self.inner = tk.Frame(self.canvas, bg=bg)
133
        self.window = self.canvas.create_window((0, 0), window=self.inner, anchor="nw")
134

    
135
        self.canvas.pack(side="left", fill="both", expand=True)
136
        self.vsb.pack(side="right", fill="y")
137

    
138
        self.inner.bind("<Configure>", self._on_frame_configure)
139
        self.canvas.bind("<Configure>", self._on_canvas_configure)
140
        self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
141

    
142
    def _on_frame_configure(self, event=None):
143
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))
144

    
145
    def _on_canvas_configure(self, event):
146
        self.canvas.itemconfig(self.window, width=event.width)
147

    
148
    def _on_mousewheel(self, event):
149
        try:
150
            self.canvas.yview_scroll(int(-1*(event.delta/120)), "units")
151
        except:
152
            pass
153

    
154
# HorizontalCard
155
class HorizontalCard(tk.Frame):
156
    def __init__(self, parent, title):
157
        super().__init__(parent, bg=CARD_BG, highlightthickness=1,
158
                         highlightbackground=BORDER, padx=12, pady=10)
159

    
160
        self.lbl_title = make_label(self, title, size=11, bold=True)
161
        self.lbl_title.pack(anchor="w")
162

    
163
        self.lbl_value = make_label(self, "--", size=14, bold=True, fg=SUBTEXT)
164
        self.lbl_value.pack(anchor="w", pady=(6,0))
165

    
166
    def set_value(self, text):
167
        self.lbl_value.config(text=text)
168

    
169
class DualHorizontalCard(tk.Frame):
170
    def __init__(self, parent, left_title, right_title):
171
        super().__init__(
172
            parent,
173
            bg=CARD_BG,
174
            highlightthickness=1,
175
            highlightbackground=BORDER,
176
            padx=12,
177
            pady=10
178
        )
179

    
180
        cont = tk.Frame(self, bg=CARD_BG)
181
        cont.pack(fill="x")
182

    
183
        # --- IZQUIERDA (Ambiente) ---
184
        left = tk.Frame(cont, bg=CARD_BG)
185
        left.pack(side="left", expand=True, fill="x")
186

    
187
        self.lbl_left_title = make_label(left, left_title, size=10, bold=True)
188
        self.lbl_left_title.pack(anchor="center")
189

    
190
        self.lbl_left_value = make_label(left, "--", size=16, bold=True)
191
        self.lbl_left_value.pack(anchor="center", pady=(6, 0))
192

    
193
        # --- SEPARADOR ---
194
        sep = tk.Frame(cont, width=1, bg=BORDER)
195
        sep.pack(side="left", fill="y", padx=8)
196

    
197
        # --- DERECHA (Suelo) ---
198
        right = tk.Frame(cont, bg=CARD_BG)
199
        right.pack(side="left", expand=True, fill="x")
200

    
201
        self.lbl_right_title = make_label(right, right_title, size=10, bold=True)
202
        self.lbl_right_title.pack(anchor="center")
203

    
204
        self.lbl_right_value = make_label(right, "--", size=16, bold=True)
205
        self.lbl_right_value.pack(anchor="center", pady=(6, 0))
206

    
207
    def set_left(self, text):
208
        self.lbl_left_value.config(text=text)
209

    
210
    def set_right(self, text):
211
        self.lbl_right_value.config(text=text)
212

    
213
    def set_value(self, text):
214
        # Compatibilidad con código antiguo
215
        self.set_left(text)
216

    
217
# AccordionSection
218
class AccordionSection(tk.Frame):
219
    def __init__(self, parent, app, key, title, config_schema):
220
        super().__init__(parent, bg=BG)
221
        self.app = app
222
        self.key = key
223
        self.title = title
224
        self.config_schema = config_schema
225
        self.config_values = {}
226
        self.is_open = False
227

    
228
        self.header = tk.Frame(self, bg=BG)
229
        self.header.pack(fill="x", pady=(6,0))
230

    
231
        self.arrow = make_label(self.header, "", size=14)
232
        self.arrow.pack(side="left", padx=(4,8))
233

    
234
        self.lbl_title = make_label(self.header, title, size=13, bold=True)
235
        self.lbl_title.pack(side="left", pady=8)
236

    
237
        self.header.bind("<Button-1>", self.toggle)
238
        self.arrow.bind("<Button-1>", self.toggle)
239
        self.lbl_title.bind("<Button-1>", self.toggle)
240

    
241
        self.body = tk.Frame(self, bg=BG)
242

    
243
        if key in ("temp", "hum"):
244
            self.card = DualHorizontalCard(
245
                self.body,
246
                "Ambiente",
247
                "Suelo"
248
            )
249
        else:
250
            self.card = HorizontalCard(self.body, title)
251

    
252
        self.card.pack(fill="x", pady=(10,6))
253
        self.card.pack(fill="x", pady=(10,6))
254

    
255
        self.actions_frame = tk.Frame(self.body, bg=BG)
256
        self.actions_frame.pack(fill="x", pady=(0,6))
257

    
258
        if key == "ph":
259
            self.btn_action = tk.Button(self.actions_frame, text="Activar Dosificador")
260
            style_button_primary(self.btn_action)
261
            self.btn_action.pack(side="left", padx=4)
262
        elif key == "temp":
263
            self.btn_action = tk.Button(self.actions_frame, text="Desplegar Mallas")
264
            style_button_primary(self.btn_action)
265
            self.btn_action.pack(side="left", padx=4)
266
        else:
267
            self.btn_action = None
268

    
269
        self.btn_config = tk.Button(self.actions_frame, text="Configurar")
270
        style_button_secondary(self.btn_config)
271
        self.btn_config.pack(side="left", padx=4)
272
        self.btn_config.config(command=self.toggle_config_panel)
273

    
274
        self.config_panel = tk.Frame(self.body, bg=BG,
275
                                     highlightthickness=1,
276
                                     highlightbackground=BORDER)
277

    
278
        self._build_config_fields()
279

    
280
        stored = self.app.config_store.get(self.key, {})
281
        for f in self.config_schema:
282
            name = f["name"]
283
            default = f.get("default", "")
284
            val = stored.get(name, default)
285
            self.config_values[name] = val
286
            if name in self.field_vars:
287
                self.field_vars[name][0].set(str(val))
288

    
289
    def _build_config_fields(self):
290
       hdr = make_label(self.config_panel, "Configuración", size=12, bold=True)
291
       hdr.pack(anchor="w", padx=10, pady=(8,6))
292

    
293
       self.field_vars = {}
294
       self.error_labels = {}
295

    
296
       for field in self.config_schema:
297
           frm = tk.Frame(self.config_panel, bg=BG)
298
           frm.pack(fill="x", padx=10, pady=2)
299

    
300
           lbl = make_label(frm, field.get("label", field["name"]), size=10)
301
           lbl.pack(anchor="w")
302

    
303
           var = tk.StringVar(value=str(field.get("default", "")))
304
           ent = tk.Entry(frm, textvariable=var)
305
           ent.pack(fill="x")
306

    
307
           err = tk.Label(frm, text="", fg="red", bg=BG, font=("Segoe UI", 9))
308
           err.pack(anchor="w")
309

    
310
           self.field_vars[field["name"]] = (var, field)
311
           self.error_labels[field["name"]] = err
312

    
313
       btns = tk.Frame(self.config_panel, bg=BG)
314
       btns.pack(fill="x", padx=10, pady=(6,10))
315

    
316
       btn_save = tk.Button(btns, text="Guardar", width=10)
317
       style_button_primary(btn_save)
318
       btn_save.pack(side="right", padx=6)
319
       btn_save.config(command=self._on_save_config)
320

    
321
       btn_cancel = tk.Button(btns, text="Cerrar", width=10, bg="#e0e0e0", relief="flat")
322
       btn_cancel.pack(side="right")
323
       btn_cancel.config(command=self.toggle_config_panel)
324

    
325
    def toggle(self, event=None):
326
        if self.is_open:
327
            self.body.pack_forget()
328
            self.arrow.config(text="")
329
            self.is_open = False
330
        else:
331
            self.body.pack(fill="x")
332
            self.arrow.config(text="")
333
            self.is_open = True
334

    
335
    def toggle_config_panel(self):
336
        if self.config_panel.winfo_ismapped():
337
            self.config_panel.pack_forget()
338
        else:
339
            self.config_panel.pack(fill="x", padx=4, pady=(6,8))
340

    
341
    def set_value(self, text):
342
        self.card.set_value(text)
343

    
344
    def _on_save_config(self):
345
        cfg = {}
346

    
347
        for lbl in self.error_labels.values():
348
            lbl.config(text="")
349

    
350
        for name, (var, meta) in self.field_vars.items():
351
            cfg[name] = var.get().strip()
352

    
353
        errores = self.validar_config(cfg)
354

    
355
        if errores:
356
            for campo, msg in errores.items():
357
                if campo in self.error_labels:
358
                    self.error_labels[campo].config(text=msg)
359
            return
360

    
361
        for name, (var, meta) in self.field_vars.items():
362
            raw = var.get().strip()
363
            t = meta.get("type", "str")
364
            try:
365
                if t == "int":
366
                    cfg[name] = int(raw)
367
                elif t == "float":
368
                    cfg[name] = float(raw)
369
                else:
370
                    cfg[name] = raw
371
            except:
372
                cfg[name] = None
373

    
374
        self.app.config_store.setdefault(self.key, {}).update(cfg)
375
        self.app.save_config_file()
376

    
377
        payload = {
378
            "type": "config",
379
            "sensor": self.key,
380
            "config": cfg,
381
            "ts": time.time()
382
        }
383

    
384
        try:
385
            self.app.outgoing_q.put_nowait(payload)
386
        except:
387
            pass
388

    
389
        self.toggle_config_panel()
390

    
391
    def actualizar_campos(self, nuevos_valores):
392
        for nombre, valor in nuevos_valores.items():
393
            if nombre in self.field_vars:
394
                self.field_vars[nombre][0].set(str(valor))
395

    
396

    
397
    def validar_config(self, cfg):
398
        errores = {}
399

    
400
        def num(x):
401
            try:
402
                return float(x)
403
            except:
404
                return None
405

    
406
        if self.key == "ph":
407
            lo = num(cfg.get("min"))
408
            hi = num(cfg.get("max"))
409
            if lo is None or hi is None:
410
                errores["min"] = "Debe ser numérico"
411
                errores["max"] = "Debe ser numérico"
412
            elif lo >= hi:
413
                errores["min"] = "Debe ser menor que el máximo"
414
                errores["max"] = "Debe ser mayor que el mínimo"
415

    
416
        if self.key == "temp":
417
            lo = num(cfg.get("amb_min"))
418
            hi = num(cfg.get("amb_max"))
419
            if lo is None or hi is None:
420
                errores["amb_min"] = "Debe ser numérico"
421
                errores["amb_max"] = "Debe ser numérico"
422
            elif lo >= hi:
423
                errores["amb_min"] = "Debe ser menor que el máximo"
424
                errores["amb_max"] = "Debe ser mayor que el mínimo"
425

    
426
        if self.key == "hum":
427
            lo = num(cfg.get("amb_min"))
428
            hi = num(cfg.get("amb_max"))
429
            if lo is None or hi is None:
430
                errores["amb_min"] = "Debe ser numérico"
431
                errores["amb_max"] = "Debe ser numérico"
432
            elif lo < 0 or hi > 100:
433
                errores["amb_min"] = "Rango válido 0–100"
434
                errores["amb_max"] = "Rango válido 0–100"
435
            elif lo >= hi:
436
                errores["amb_min"] = "Debe ser menor que el máximo"
437
                errores["amb_max"] = "Debe ser mayor que el mínimo"
438

    
439
        if self.key == "npk":
440
            for nutr in ("n", "p", "k"):
441
                lo = num(cfg.get(f"{nutr}_min"))
442
                hi = num(cfg.get(f"{nutr}_max"))
443
                if lo is None or hi is None:
444
                    errores[f"{nutr}_min"] = "Debe ser numérico"
445
                    errores[f"{nutr}_max"] = "Debe ser numérico"
446
                elif lo < 0 or hi < 0:
447
                    errores[f"{nutr}_min"] = "No puede ser negativo"
448
                    errores[f"{nutr}_max"] = "No puede ser negativo"
449
                elif lo >= hi:
450
                    errores[f"{nutr}_min"] = "Debe ser menor que el máximo"
451
                    errores[f"{nutr}_max"] = "Debe ser mayor que el mínimo"
452

    
453
        return errores
454

    
455

    
456
# MAIN APP
457
class MainApp(tk.Tk):
458
    def __init__(self, incoming_q, outgoing_q):
459
        super().__init__()
460
        self.title("InverTrack")
461
        self.configure(bg=BG)
462
        self.geometry("520x700")
463
        self.resizable(True, True)
464

    
465
        self.incoming_q = incoming_q
466
        self.outgoing_q = outgoing_q
467
        self.config_store = {}
468

    
469
        self._load_config_file()
470

    
471
        self.alertas = AlertaManager()
472

    
473
        hdr = tk.Frame(self, bg=BG)
474
        hdr.pack(fill="x", pady=(12,4))
475
        make_label(hdr, "InverTrack", size=20, bold=True).pack()
476
        make_label(hdr, "Panel de monitoreo", size=11, fg=SUBTEXT).pack()
477

    
478
        sf = ScrollFrame(self, bg=BG)
479
        sf.pack(fill="both", expand=True, padx=12, pady=12)
480
        container = sf.inner
481

    
482
        # MENÚ LÍMITES PREDEFINIDOS
483

    
484

    
485
        ph_schema = [
486
            {"name":"min", "label":"Mínimo (pH)", "type":"float", "default":"5.5"},
487
            {"name":"max", "label":"Máximo (pH)", "type":"float", "default":"7.0"},
488
        ]
489
        npk_schema = [
490
            {"name":"n_min","label":"N min","type":"float","default":"0"},
491
            {"name":"n_max","label":"N max","type":"float","default":"100"},
492
            {"name":"p_min","label":"P min","type":"float","default":"0"},
493
            {"name":"p_max","label":"P max","type":"float","default":"100"},
494
            {"name":"k_min","label":"K min","type":"float","default":"0"},
495
            {"name":"k_max","label":"K max","type":"float","default":"100"},
496
        ]
497
        temp_schema = [
498
            {"name":"amb_min","label":"Ambiente min (°C)","type":"float","default":"12"},
499
            {"name":"amb_max","label":"Ambiente max (°C)","type":"float","default":"35"},
500
        ]
501
        hum_schema = [
502
            {"name":"amb_min","label":"Ambiente min (%)","type":"float","default":"30"},
503
            {"name":"amb_max","label":"Ambiente max (%)","type":"float","default":"80"},
504
        ]
505

    
506
        self.sections = {}
507
        self.sections["ph"] = AccordionSection(container, self, "ph", "pH del agua", ph_schema)
508
        self.sections["ph"].pack(fill="x", pady=8)
509

    
510
        self.sections["npk"] = AccordionSection(container, self, "npk", "NPK (N-P-K)", npk_schema)
511
        self.sections["npk"].pack(fill="x", pady=8)
512

    
513
        self.sections["temp"] = AccordionSection(container, self, "temp", "Temperaturas", temp_schema)
514
        self.sections["temp"].pack(fill="x", pady=8)
515

    
516
        self.sections["hum"] = AccordionSection(container, self, "hum", "Humedad", hum_schema)
517
        self.sections["hum"].pack(fill="x", pady=8)
518

    
519
        self.presets_section = AccordionSection(
520
            container,
521
            self,
522
            "presets",
523
            "Límites Predefinidos",
524
            []
525
        )
526
        self.presets_section.pack(fill="x", pady=8)
527

    
528
        for cultivo in CULTIVOS_PREDEFINIDOS:
529
            fila = tk.Frame(self.presets_section.body, bg=BG)
530
            fila.pack(fill="x", padx=12, pady=6)
531

    
532
            make_label(fila, cultivo, size=11).pack(side="left")
533

    
534
            btn = tk.Button(
535
                fila,
536
                text="Asignar",
537
                command=lambda c=cultivo: self.aplicar_cultivo(c)
538
            )
539
            style_button_primary(btn)
540
            btn.pack(side="right")
541

    
542
        # Mantener cerrado por defecto
543
        self.presets_section.body.pack_forget()
544

    
545
        if hasattr(self.presets_section, "btn_config"):
546
           self.presets_section.btn_config.pack_forget()
547
        
548
        if self.presets_section.key == "presets":
549
            # Quitar tarjeta de valor (--)
550
            if hasattr(self.presets_section, "card"):
551
                self.presets_section.card.pack_forget()
552

    
553
            # Quitar frame de acciones (configurar / automatizaciones)
554
            if hasattr(self.presets_section, "actions_frame"):
555
                self.presets_section.actions_frame.pack_forget()
556

    
557
        self.presets_section.body.pack_forget()
558

    
559
        btn_exit = tk.Button(self, text="Salir", bg="#e0e0e0", relief="flat", command=self.on_close)
560
        btn_exit.pack(side="bottom", fill="x", padx=8, pady=8)
561

    
562
        self.after(POLL_MS, self._poll_incoming)
563

    
564
    def _load_config_file(self):
565
        try:
566
            if os.path.exists(CONFIG_FILE):
567
                with open(CONFIG_FILE, "r", encoding="utf-8") as f:
568
                    self.config_store = json.load(f)
569
            else:
570
                self.config_store = {}
571
        except:
572
            self.config_store = {}
573

    
574
    def save_config_file(self):
575
        try:
576
            with open(CONFIG_FILE + ".tmp", "w", encoding="utf-8") as f:
577
                json.dump(self.config_store, f, indent=2, ensure_ascii=False)
578
            os.replace(CONFIG_FILE + ".tmp", CONFIG_FILE)
579
        except Exception as e:
580
            print("Error guardando config:", e)
581
    
582
    def aplicar_cultivo(self, nombre):
583
        if nombre not in CULTIVOS_PREDEFINIDOS:
584
            return
585

    
586
        datos = CULTIVOS_PREDEFINIDOS[nombre]
587

    
588
        # Aplicar los nuevos valores a config_store
589
        for sensor, cfg in datos.items():
590
            self.config_store.setdefault(sensor, {}).update(cfg)
591

    
592
            if sensor in self.sections:
593
                # Actualizamos los valores en la UI usando actualizar_campos
594
                self.sections[sensor].actualizar_campos(cfg)
595

    
596
            payload = {
597
                "type": "config",
598
                "sensor": sensor,
599
                "config": cfg,
600
                "ts": time.time()
601
            }
602

    
603
            try:
604
                self.outgoing_q.put_nowait(payload)
605
            except:
606
                pass
607

    
608
        # Guardar configuración en el archivo local
609
        self.save_config_file()
610

    
611
        # Notificar que se aplicó el cultivo
612
        enviar_notificacion(f"Límites predefinidos aplicados: {nombre}")
613
            
614

    
615
    def _poll_incoming(self):
616
        try:
617
            while True:
618
                item = self.incoming_q.get_nowait()
619

    
620
                if "temp" in item and item.get("temp") is not None:
621
                    t_raw = item.get("temp")
622
                    try:
623
                        t = float(t_raw)
624
                    except:
625
                        t = None
626

    
627
                    if t is not None:
628
                        self.sections["temp"].set_value(f"Ambiente: {t} °C")
629

    
630
                        cfg = self.config_store.get("temp", {})
631
                        amb_min = cfg.get("amb_min")
632
                        amb_max = cfg.get("amb_max")
633

    
634
                        try:
635
                            amb_min = float(amb_min) if amb_min is not None else None
636
                        except:
637
                            amb_min = None
638
                        try:
639
                            amb_max = float(amb_max) if amb_max is not None else None
640
                        except:
641
                            amb_max = None
642

    
643
                        fuera = False
644
                        if amb_min is not None and t < amb_min:
645
                            fuera = True
646
                        if amb_max is not None and t > amb_max:
647
                            fuera = True
648

    
649
                        if fuera and self.alertas.debe_alertar("temp_amb"):
650
                            enviar_notificacion(f"Temperatura ambiente fuera de rango: {t} °C")
651

    
652
                if "hum" in item and item.get("hum") is not None:
653
                    h_raw = item.get("hum")
654
                    try:
655
                        h = float(h_raw)
656
                    except:
657
                        h = None
658

    
659
                    if h is not None:
660
                        self.sections["hum"].set_value(f"Ambiente: {h}%")
661

    
662
                        cfg = self.config_store.get("hum", {})
663
                        amb_min = cfg.get("amb_min")
664
                        amb_max = cfg.get("amb_max")
665

    
666
                        try:
667
                            amb_min = float(amb_min) if amb_min is not None else None
668
                        except:
669
                            amb_min = None
670
                        try:
671
                            amb_max = float(amb_max) if amb_max is not None else None
672
                        except:
673
                            amb_max = None
674

    
675
                        fuera = False
676
                        if amb_min is not None and h < amb_min:
677
                            fuera = True
678
                        if amb_max is not None and h > amb_max:
679
                            fuera = True
680

    
681
                        if fuera and self.alertas.debe_alertar("hum_amb"):
682
                            enviar_notificacion(f"Humedad ambiente fuera de rango: {h}%")
683

    
684
                if "valor_adc" in item and item.get("valor_adc") is not None:
685
                    try:
686
                        ph = float(item.get("valor_adc"))
687
                    except:
688
                        ph = None
689

    
690
                    if ph is not None:
691
                        self.sections["ph"].set_value(f"{ph} pH")
692

    
693
                        cfg = self.config_store.get("ph", {})
694
                        try:
695
                            lo = float(cfg.get("min")) if cfg.get("min") is not None else None
696
                        except:
697
                            lo = None
698
                        try:
699
                            hi = float(cfg.get("max")) if cfg.get("max") is not None else None
700
                        except:
701
                            hi = None
702

    
703
                        fuera = False
704
                        if lo is not None and ph < lo:
705
                            fuera = True
706
                        if hi is not None and ph > hi:
707
                            fuera = True
708

    
709
                        if fuera and self.alertas.debe_alertar("ph"):
710
                            enviar_notificacion(f"pH fuera de rango: {ph}")
711

    
712
                if any(k in item for k in ("n", "p", "k")):
713
                    n_raw = item.get("n")
714
                    p_raw = item.get("p")
715
                    k_raw = item.get("k")
716

    
717
                    n_disp = n_raw if n_raw is not None else "--"
718
                    p_disp = p_raw if p_raw is not None else "--"
719
                    k_disp = k_raw if k_raw is not None else "--"
720
                    self.sections["npk"].set_value(f"N: {n_disp}   P: {p_disp}   K: {k_disp}")
721

    
722
                    cfg = self.config_store.get("npk", {})
723

    
724
                    try:
725
                        n_val = float(n_raw)
726
                        n_min = float(cfg.get("n_min")) if cfg.get("n_min") is not None else None
727
                        n_max = float(cfg.get("n_max")) if cfg.get("n_max") is not None else None
728
                    except:
729
                        n_val = None
730
                        n_min = None
731
                        n_max = None
732

    
733
                    if n_val is not None:
734
                        fuera = False
735
                        if n_min is not None and n_val < n_min:
736
                            fuera = True
737
                        if n_max is not None and n_val > n_max:
738
                            fuera = True
739
                        if fuera and self.alertas.debe_alertar("npk_n"):
740
                            enviar_notificacion(f"N fuera de rango: {n_val}")
741

    
742
                    try:
743
                        p_val = float(p_raw)
744
                        p_min = float(cfg.get("p_min")) if cfg.get("p_min") is not None else None
745
                        p_max = float(cfg.get("p_max")) if cfg.get("p_max") is not None else None
746
                    except:
747
                        p_val = None
748
                        p_min = None
749
                        p_max = None
750

    
751
                    if p_val is not None:
752
                        fuera = False
753
                        if p_min is not None and p_val < p_min:
754
                            fuera = True
755
                        if p_max is not None and p_val > p_max:
756
                            fuera = True
757
                        if fuera and self.alertas.debe_alertar("npk_p"):
758
                            enviar_notificacion(f"P fuera de rango: {p_val}")
759

    
760
                    try:
761
                        k_val = float(k_raw)
762
                        k_min = float(cfg.get("k_min")) if cfg.get("k_min") is not None else None
763
                        k_max = float(cfg.get("k_max")) if cfg.get("k_max") is not None else None
764
                    except:
765
                        k_val = None
766
                        k_min = None
767
                        k_max = None
768

    
769
                    if k_val is not None:
770
                        fuera = False
771
                        if k_min is not None and k_val < k_min:
772
                            fuera = True
773
                        if k_max is not None and k_val > k_max:
774
                            fuera = True
775
                        if fuera and self.alertas.debe_alertar("npk_k"):
776
                            enviar_notificacion(f"K fuera de rango: {k_val}")
777

    
778
        except queue.Empty:
779
            pass
780

    
781
        self.after(POLL_MS, self._poll_incoming)
782

    
783
    def on_close(self):
784
        self.destroy()
785

    
786
# SensorClient
787
class SensorClient:
788
    def __init__(self, incoming_q, outgoing_q, uri=WS_URI):
789
        self.incoming_q = incoming_q
790
        self.outgoing_q = outgoing_q
791
        self.uri = uri
792
        self.thread = None
793
        self._stop = threading.Event()
794

    
795
    def start(self):
796
        if self.thread and self.thread.is_alive():
797
            return
798
        self.thread = threading.Thread(target=self._run, daemon=True)
799
        self.thread.start()
800

    
801
    def stop(self):
802
        self._stop.set()
803

    
804
    def _run(self):
805
        asyncio.run(self._main())
806

    
807
    async def _main(self):
808
        backoff = 1
809
        while not self._stop.is_set():
810
            try:
811
                async with websockets.connect(self.uri) as ws:
812
                    print("SensorClient: conectado a", self.uri)
813

    
814
                    # NOTIFICACIÓN: CONECTADO
815
                    enviar_notificacion("Raspberry conectada al servidor de sensores.")
816

    
817
                    sender_task = asyncio.create_task(self._sender(ws))
818
                    try:
819
                        async for msg in ws:
820
                            try:
821
                                data = json.loads(msg)
822
                            except:
823
                                continue
824

    
825
                            normalized = {
826
                                "temp": data.get("temp_ambiente", data.get("temp")),
827
                                "hum": data.get("hum_ambiente", data.get("hum")),
828
                                "valor_adc": data.get("valor_adc", data.get("valor ADC")),
829
                                "n": data.get("n"),
830
                                "p": data.get("p"),
831
                                "k": data.get("k"),
832
                            }
833

    
834
                            try:
835
                                self.incoming_q.put_nowait(normalized)
836
                            except queue.Full:
837
                                pass
838

    
839
                    finally:
840
                        sender_task.cancel()
841

    
842
            except Exception as e:
843
                print("SensorClient: error conexión ->", e)
844

    
845
                # NOTIFICACIÓN: DESCONECTADO
846
                enviar_notificacion("Raspberry desconectada del servidor de sensores.")
847

    
848
                await asyncio.sleep(backoff)
849
                backoff = min(backoff * 2, 30)
850

    
851
    async def _sender(self, ws):
852
        loop = asyncio.get_event_loop()
853
        while True:
854
            try:
855
                payload = await loop.run_in_executor(None, self.outgoing_q.get)
856
                try:
857
                    await ws.send(json.dumps(payload))
858
                except Exception as exc:
859
                    print("SensorClient: error enviando payload:", exc)
860
            except Exception:
861
                await asyncio.sleep(0.1)
862

    
863
# Boot
864
def main():
865
    incoming_q = queue.Queue(maxsize=500)
866
    outgoing_q = queue.Queue(maxsize=200)
867

    
868
    client = SensorClient(incoming_q, outgoing_q, uri=WS_URI)
869
    client.start()
870

    
871
    app = MainApp(incoming_q, outgoing_q)
872
    try:
873
        app.mainloop()
874
    finally:
875
        client.stop()
876

    
877
if __name__ == "__main__":
878
    main()