|
1
|
from flask import Flask, jsonify, request, Response
|
|
2
|
import grovepi
|
|
3
|
import time
|
|
4
|
import threading
|
|
5
|
import json
|
|
6
|
import datetime
|
|
7
|
import os
|
|
8
|
import cv2
|
|
9
|
|
|
10
|
app = Flask(__name__)
|
|
11
|
|
|
12
|
@app.after_request
|
|
13
|
def after_request(response):
|
|
14
|
response.headers.add('Access-Control-Allow-Origin', '*')
|
|
15
|
response.headers.add('Access-Control-Allow-Headers', 'Content-Type')
|
|
16
|
response.headers.add('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
|
|
17
|
return response
|
|
18
|
|
|
19
|
ULTRA_COMIDA = 4
|
|
20
|
ULTRA_AGUA = 3
|
|
21
|
RELAY_AGUA = 8
|
|
22
|
RELAY_COMIDA = 2
|
|
23
|
|
|
24
|
grovepi.pinMode(ULTRA_COMIDA, "INPUT")
|
|
25
|
grovepi.pinMode(ULTRA_AGUA, "INPUT")
|
|
26
|
grovepi.pinMode(RELAY_AGUA, "OUTPUT")
|
|
27
|
grovepi.pinMode(RELAY_COMIDA, "OUTPUT")
|
|
28
|
|
|
29
|
|
|
30
|
CONFIG_DISPENSADOR = {
|
|
31
|
"altura_total": 26,
|
|
32
|
"niveles": {
|
|
33
|
"comida": {
|
|
34
|
"lleno": (0, 6),
|
|
35
|
"medio": (7, 12),
|
|
36
|
"poco": (13, 19),
|
|
37
|
"vacio": (20, 26)
|
|
38
|
},
|
|
39
|
"agua": {
|
|
40
|
"lleno": (0, 6),
|
|
41
|
"medio": (7, 12),
|
|
42
|
"poco": (13, 19),
|
|
43
|
"vacio": (20, 26)
|
|
44
|
}
|
|
45
|
}
|
|
46
|
}
|
|
47
|
|
|
48
|
|
|
49
|
estado = {
|
|
50
|
"comida": {
|
|
51
|
"distancia_cm": -1,
|
|
52
|
"nivel": "desconocido",
|
|
53
|
"porcentaje": 0
|
|
54
|
},
|
|
55
|
"agua": {
|
|
56
|
"distancia_cm": -1,
|
|
57
|
"nivel": "desconocido",
|
|
58
|
"porcentaje": 0
|
|
59
|
}
|
|
60
|
}
|
|
61
|
|
|
62
|
|
|
63
|
camera = None
|
|
64
|
camera_active = False
|
|
65
|
CONFIG_FILE = "config.json"
|
|
66
|
|
|
67
|
|
|
68
|
def init_camera():
|
|
69
|
"""Inicializa la cámara"""
|
|
70
|
global camera, camera_active
|
|
71
|
|
|
72
|
if camera_active and camera is not None:
|
|
73
|
return True
|
|
74
|
|
|
75
|
|
|
76
|
if camera is not None:
|
|
77
|
camera.release()
|
|
78
|
|
|
79
|
|
|
80
|
camera = cv2.VideoCapture(0)
|
|
81
|
|
|
82
|
if not camera.isOpened():
|
|
83
|
|
|
84
|
camera = cv2.VideoCapture(1)
|
|
85
|
if not camera.isOpened():
|
|
86
|
print(" No se pudo conectar a la cámara")
|
|
87
|
camera = None
|
|
88
|
camera_active = False
|
|
89
|
return False
|
|
90
|
|
|
91
|
|
|
92
|
camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
93
|
camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
94
|
camera.set(cv2.CAP_PROP_FPS, 15)
|
|
95
|
|
|
96
|
camera_active = True
|
|
97
|
print(" Cámara inicializada correctamente")
|
|
98
|
return True
|
|
99
|
|
|
100
|
def release_camera():
|
|
101
|
"""Libera la cámara"""
|
|
102
|
global camera, camera_active
|
|
103
|
|
|
104
|
if camera is not None:
|
|
105
|
camera.release()
|
|
106
|
camera = None
|
|
107
|
|
|
108
|
camera_active = False
|
|
109
|
print(" Cámara liberada")
|
|
110
|
|
|
111
|
|
|
112
|
def cargar_config():
|
|
113
|
try:
|
|
114
|
with open(CONFIG_FILE, "r") as f:
|
|
115
|
return json.load(f)
|
|
116
|
except:
|
|
117
|
return {"horarios": []}
|
|
118
|
|
|
119
|
def guardar_config(data):
|
|
120
|
with open(CONFIG_FILE, "w") as f:
|
|
121
|
json.dump(data, f, indent=2)
|
|
122
|
|
|
123
|
def calcular_nivel(distancia_cm, tipo):
|
|
124
|
if distancia_cm == -1:
|
|
125
|
return "error", 0
|
|
126
|
|
|
127
|
config = CONFIG_DISPENSADOR["niveles"][tipo]
|
|
128
|
|
|
129
|
if config["lleno"][0] <= distancia_cm <= config["lleno"][1]:
|
|
130
|
porcentaje = 100 - ((distancia_cm / config["lleno"][1]) * 30)
|
|
131
|
return "lleno", max(80, min(100, porcentaje))
|
|
132
|
|
|
133
|
elif config["medio"][0] <= distancia_cm <= config["medio"][1]:
|
|
134
|
porcentaje = 70 - (((distancia_cm - 7) / 5) * 20)
|
|
135
|
return "medio", max(50, min(79, porcentaje))
|
|
136
|
|
|
137
|
elif config["poco"][0] <= distancia_cm <= config["poco"][1]:
|
|
138
|
porcentaje = 50 - (((distancia_cm - 13) / 6) * 30)
|
|
139
|
return "poco", max(20, min(49, porcentaje))
|
|
140
|
|
|
141
|
else:
|
|
142
|
return "vacio", max(0, min(19, 100 - (distancia_cm * 3)))
|
|
143
|
|
|
144
|
def leer_sensores():
|
|
145
|
global estado
|
|
146
|
while True:
|
|
147
|
try:
|
|
148
|
distancia = grovepi.ultrasonicRead(ULTRA_COMIDA)
|
|
149
|
nivel, porcentaje = calcular_nivel(distancia, "comida")
|
|
150
|
estado["comida"] = {
|
|
151
|
"distancia_cm": distancia,
|
|
152
|
"nivel": nivel,
|
|
153
|
"porcentaje": round(porcentaje, 1)
|
|
154
|
}
|
|
155
|
except Exception as e:
|
|
156
|
print(f"Error sensor comida: {e}")
|
|
157
|
estado["comida"]["nivel"] = "error"
|
|
158
|
|
|
159
|
try:
|
|
160
|
distancia = grovepi.ultrasonicRead(ULTRA_AGUA)
|
|
161
|
nivel, porcentaje = calcular_nivel(distancia, "agua")
|
|
162
|
estado["agua"] = {
|
|
163
|
"distancia_cm": distancia,
|
|
164
|
"nivel": nivel,
|
|
165
|
"porcentaje": round(porcentaje, 1)
|
|
166
|
}
|
|
167
|
except Exception as e:
|
|
168
|
print(f"Error sensor agua: {e}")
|
|
169
|
estado["agua"]["nivel"] = "error"
|
|
170
|
|
|
171
|
time.sleep(2)
|
|
172
|
|
|
173
|
def activar_relay(tipo, segundos):
|
|
174
|
if tipo == "agua":
|
|
175
|
grovepi.digitalWrite(RELAY_AGUA, 1)
|
|
176
|
time.sleep(segundos)
|
|
177
|
grovepi.digitalWrite(RELAY_AGUA, 0)
|
|
178
|
|
|
179
|
def scheduler():
|
|
180
|
while True:
|
|
181
|
ahora = datetime.datetime.now()
|
|
182
|
hora = ahora.strftime("%H:%M")
|
|
183
|
dia = ahora.strftime("%A")
|
|
184
|
|
|
185
|
config = cargar_config()
|
|
186
|
|
|
187
|
for h in config["horarios"]:
|
|
188
|
if not h["activo"]:
|
|
189
|
continue
|
|
190
|
if h["hora"] != hora:
|
|
191
|
continue
|
|
192
|
if dia not in h["dias"]:
|
|
193
|
continue
|
|
194
|
|
|
195
|
activar_relay(h["tipo"], h["tiempo"])
|
|
196
|
|
|
197
|
time.sleep(60)
|
|
198
|
|
|
199
|
|
|
200
|
@app.route("/")
|
|
201
|
def index():
|
|
202
|
return jsonify({
|
|
203
|
"app": "Dispensador IoT",
|
|
204
|
"status": "running",
|
|
205
|
"endpoints": {
|
|
206
|
"/estado": "GET - Estado sensores",
|
|
207
|
"/niveles": "GET - Niveles procesados",
|
|
208
|
"/dispensar": "POST - Dispensar manual",
|
|
209
|
"/horarios": "GET/POST - Horarios",
|
|
210
|
"/video": "GET - Stream de video MJPEG",
|
|
211
|
"/foto": "GET - Foto en tiempo real (memoria)",
|
|
212
|
"/foto-guardar": "POST - Tomar y guardar foto en disco"
|
|
213
|
}
|
|
214
|
})
|
|
215
|
|
|
216
|
@app.route("/estado", methods=["GET"])
|
|
217
|
def get_estado():
|
|
218
|
return jsonify({
|
|
219
|
"comida": estado["comida"],
|
|
220
|
"agua": estado["agua"],
|
|
221
|
"timestamp": datetime.datetime.now().isoformat()
|
|
222
|
})
|
|
223
|
|
|
224
|
@app.route("/niveles", methods=["GET"])
|
|
225
|
def get_niveles():
|
|
226
|
return jsonify({
|
|
227
|
"comida": {
|
|
228
|
"nivel": estado["comida"]["nivel"],
|
|
229
|
"porcentaje": estado["comida"]["porcentaje"]
|
|
230
|
},
|
|
231
|
"agua": {
|
|
232
|
"nivel": estado["agua"]["nivel"],
|
|
233
|
"porcentaje": estado["agua"]["porcentaje"]
|
|
234
|
}
|
|
235
|
})
|
|
236
|
|
|
237
|
@app.route("/dispensar", methods=["POST"])
|
|
238
|
def dispensar_manual():
|
|
239
|
data = request.json
|
|
240
|
activar_relay(data["tipo"], data["tiempo"])
|
|
241
|
return jsonify({"ok": True})
|
|
242
|
|
|
243
|
@app.route("/horarios", methods=["GET"])
|
|
244
|
def get_horarios():
|
|
245
|
return jsonify(cargar_config()["horarios"])
|
|
246
|
|
|
247
|
@app.route("/horarios", methods=["POST"])
|
|
248
|
def add_horario():
|
|
249
|
data = request.json
|
|
250
|
config = cargar_config()
|
|
251
|
|
|
252
|
config["horarios"].append({
|
|
253
|
"hora": data["hora"],
|
|
254
|
"dias": data["dias"],
|
|
255
|
"tipo": data["tipo"],
|
|
256
|
"tiempo": data["tiempo"],
|
|
257
|
"activo": True
|
|
258
|
})
|
|
259
|
|
|
260
|
guardar_config(config)
|
|
261
|
return jsonify({"ok": True})
|
|
262
|
|
|
263
|
|
|
264
|
@app.route("/video")
|
|
265
|
def video():
|
|
266
|
"""Stream MJPEG para WebView"""
|
|
267
|
|
|
268
|
if not camera_active:
|
|
269
|
if not init_camera():
|
|
270
|
return "No se pudo inicializar la cámara", 500
|
|
271
|
|
|
272
|
def generate():
|
|
273
|
while camera_active:
|
|
274
|
success, frame = camera.read()
|
|
275
|
if not success:
|
|
276
|
break
|
|
277
|
|
|
278
|
|
|
279
|
ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
280
|
|
|
281
|
if ret:
|
|
282
|
frame_bytes = buffer.tobytes()
|
|
283
|
yield (b'--frame\r\n'
|
|
284
|
b'Content-Type: image/jpeg\r\n\r\n' +
|
|
285
|
frame_bytes + b'\r\n')
|
|
286
|
|
|
287
|
|
|
288
|
time.sleep(0.066)
|
|
289
|
|
|
290
|
return Response(
|
|
291
|
generate(),
|
|
292
|
mimetype='multipart/x-mixed-replace; boundary=frame'
|
|
293
|
)
|
|
294
|
|
|
295
|
@app.route("/foto")
|
|
296
|
def foto():
|
|
297
|
"""Foto en tiempo real (solo en memoria) - NUEVA VERSIÓN"""
|
|
298
|
try:
|
|
299
|
|
|
300
|
if not camera_active:
|
|
301
|
if not init_camera():
|
|
302
|
return jsonify({"error": "No se pudo inicializar la cámara"}), 500
|
|
303
|
|
|
304
|
success, frame = camera.read()
|
|
305
|
if not success:
|
|
306
|
return jsonify({"error": "No se pudo capturar frame"}), 500
|
|
307
|
|
|
308
|
|
|
309
|
ret, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
|
310
|
|
|
311
|
if not ret:
|
|
312
|
return jsonify({"error": "Error procesando imagen"}), 500
|
|
313
|
|
|
314
|
image_bytes = buffer.tobytes()
|
|
315
|
|
|
316
|
return Response(
|
|
317
|
image_bytes,
|
|
318
|
mimetype="image/jpeg",
|
|
319
|
headers={
|
|
320
|
"Content-Disposition": "inline; filename=foto.jpg",
|
|
321
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
322
|
"Pragma": "no-cache",
|
|
323
|
"Expires": "0"
|
|
324
|
}
|
|
325
|
)
|
|
326
|
|
|
327
|
except Exception as e:
|
|
328
|
print(f"❌ Error en endpoint /foto: {e}")
|
|
329
|
return jsonify({"error": str(e)}), 500
|
|
330
|
|
|
331
|
@app.route("/foto-guardar", methods=["POST"])
|
|
332
|
def foto_guardar():
|
|
333
|
"""Tomar y guardar foto en disco (opcional)"""
|
|
334
|
try:
|
|
335
|
if not camera_active:
|
|
336
|
if not init_camera():
|
|
337
|
return jsonify({"error": "No se pudo inicializar la cámara"}), 500
|
|
338
|
|
|
339
|
success, frame = camera.read()
|
|
340
|
if not success:
|
|
341
|
return jsonify({"error": "No se pudo capturar frame"}), 500
|
|
342
|
|
|
343
|
|
|
344
|
carpeta_destino = "mascotas"
|
|
345
|
if not os.path.exists(carpeta_destino):
|
|
346
|
os.makedirs(carpeta_destino)
|
|
347
|
|
|
348
|
ahora = datetime.datetime.now()
|
|
349
|
nombre_archivo = ahora.strftime("MASCOTA_%Y-%m-%d_%H-%M-%S.jpg")
|
|
350
|
ruta_completa = os.path.join(carpeta_destino, nombre_archivo)
|
|
351
|
|
|
352
|
exito = cv2.imwrite(ruta_completa, frame)
|
|
353
|
|
|
354
|
if exito:
|
|
355
|
return jsonify({
|
|
356
|
"success": True,
|
|
357
|
"message": "Foto guardada en el servidor",
|
|
358
|
"filename": nombre_archivo,
|
|
359
|
"path": ruta_completa,
|
|
360
|
"timestamp": ahora.isoformat()
|
|
361
|
})
|
|
362
|
else:
|
|
363
|
return jsonify({"error": "No se pudo guardar la foto"}), 500
|
|
364
|
|
|
365
|
except Exception as e:
|
|
366
|
print(f"❌ Error en endpoint /foto-guardar: {e}")
|
|
367
|
return jsonify({"error": str(e)}), 500
|
|
368
|
|
|
369
|
|
|
370
|
if __name__ == "__main__":
|
|
371
|
|
|
372
|
threading.Thread(target=leer_sensores, daemon=True).start()
|
|
373
|
threading.Thread(target=scheduler, daemon=True).start()
|
|
374
|
|
|
375
|
print("=" * 60)
|
|
376
|
print(" DISPENSADOR IoT - FOTO EN MEMORIA")
|
|
377
|
print("=" * 60)
|
|
378
|
print("\n SENSORES INICIADOS:")
|
|
379
|
print(f" Comida: D{ULTRA_COMIDA}")
|
|
380
|
print(f" Agua: D{ULTRA_AGUA}")
|
|
381
|
print(f" Relay agua: D{RELAY_AGUA}")
|
|
382
|
|
|
383
|
print("\n ENDPOINTS CÁMARA:")
|
|
384
|
print(" GET /video - Stream MJPEG")
|
|
385
|
print(" GET /foto - Foto en tiempo real (memoria)")
|
|
386
|
print(" POST /foto-guardar - Tomar y guardar foto en disco")
|
|
387
|
|
|
388
|
print("=" * 60)
|
|
389
|
print(f"\n Servidor iniciado en: http://0.0.0.0:5000")
|
|
390
|
print(" Video en vivo: http://<TU_IP>:5000/video")
|
|
391
|
print(" Foto instantánea: http://<TU_IP>:5000/foto")
|
|
392
|
print("=" * 60)
|
|
393
|
|
|
394
|
try:
|
|
395
|
app.run(host="0.0.0.0", port=5000, threaded=True, debug=False)
|
|
396
|
except KeyboardInterrupt:
|
|
397
|
print("\n Cerrando servidor...")
|
|
398
|
release_camera()
|
|
399
|
finally:
|
|
400
|
release_camera()
|