|
1
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
47
|
ALERTA_COOLDOWN = 60
|
|
48
|
|
|
49
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
194
|
sep = tk.Frame(cont, width=1, bg=BORDER)
|
|
195
|
sep.pack(side="left", fill="y", padx=8)
|
|
196
|
|
|
197
|
|
|
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
|
|
|
215
|
self.set_left(text)
|
|
216
|
|
|
217
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
550
|
if hasattr(self.presets_section, "card"):
|
|
551
|
self.presets_section.card.pack_forget()
|
|
552
|
|
|
553
|
|
|
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
|
|
|
589
|
for sensor, cfg in datos.items():
|
|
590
|
self.config_store.setdefault(sensor, {}).update(cfg)
|
|
591
|
|
|
592
|
if sensor in self.sections:
|
|
593
|
|
|
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
|
|
|
609
|
self.save_config_file()
|
|
610
|
|
|
611
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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()
|