Files
site/WESP_REL/app/routes/legacy_misc.py
T
2026-07-17 12:57:18 +03:00

584 lines
21 KiB
Python

import time
import uuid
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
from flask import Blueprint, current_app, jsonify, request, send_from_directory, session
from sqlalchemy import select
from app import db
from app.models import (
Component,
ComponentLoadingTime,
FeedDispenser,
FeedMixer,
FeedingLocation,
FeedingPeriod,
FeedingPoint,
Ingredient,
LoadingReport,
LoadingReportComponent,
PeriodRecipe,
Recipe,
Trip,
UnloadingGroup,
UnloadingReport,
UnloadingReportGroup,
)
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal, require_paired_terminal
from app.services.daily_plan.loading_recipe import plan_ingredients_for_loading
from app.services.update_api_service import (
build_updates_status_payload,
force_check_updates,
install_pending_update,
)
from app.services.hardware import GPIOController
from config import write_sync_client_state
bp = Blueprint("legacy_misc", __name__, url_prefix="/api")
class _LoadingRuntime:
def __init__(self) -> None:
self._lock = Lock()
self.current_recipe_id: Optional[str] = None
self.current_component_index = 0
self.weight_at_current_component_start = 0.0
self.is_mixing_mode = False
self.mixing_timer_active = False
self.navigation_commands_queue: List[Dict[str, Any]] = []
def snapshot(self) -> Dict[str, Any]:
with self._lock:
return {
"current_recipe_id": self.current_recipe_id,
"current_component_index": self.current_component_index,
"weight_at_current_component_start": self.weight_at_current_component_start,
"is_mixing_mode": self.is_mixing_mode,
"mixing_timer_active": self.mixing_timer_active,
}
_runtime = _LoadingRuntime()
def _error(message: str, status_code: int = 400):
return jsonify({"error": True, "message": message}), status_code
def _get_active_recipe(recipe_id: Optional[str]) -> Optional[Recipe]:
if not recipe_id:
return None
return db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
def _get_active_ingredients(recipe: Recipe) -> List[Ingredient]:
rows = [i for i in (recipe.ingredients or []) if not getattr(i, "is_deleted", False)]
return sorted(rows, key=lambda x: (getattr(x, "order", 0), x.created_at))
def _loading_ingredient_rows(recipe: Recipe) -> List[Dict[str, Any]]:
"""Веса и состав для экрана оператора — overlay «План на день», как на киоске."""
return plan_ingredients_for_loading(recipe)
def _current_weight() -> float:
"""Текущий вес с тех же весов, что и GET /current_weight (ScalesReader)."""
try:
from app.routes import scales as scales_module
reader = scales_module._get_reader()
return float(reader.get_current_weight())
except Exception:
return 0.0
@bp.post("/set_mixing_mode")
@require_auth
def set_mixing_mode():
data = request.get_json(silent=True) or {}
with _runtime._lock:
_runtime.is_mixing_mode = bool(data.get("is_mixing", False))
return jsonify({"status": "success"})
@bp.post("/led/<string:state>")
@require_auth
def control_led(state: str):
try:
controller = GPIOController.get_instance(
pin=int(current_app.config.get("GPIO_LED_PIN", 18)),
simulation_mode=bool(current_app.config.get("SIMULATION_MODE", False)),
)
if state == "on":
controller.on()
return jsonify({"status": "success", "message": "Светодиод включен"})
if state == "off":
controller.off()
return jsonify({"status": "success", "message": "Светодиод выключен"})
if state == "blink":
payload = request.get_json(silent=True) or {}
times = int(payload.get("times", 1) or 1)
delay = float(payload.get("delay", 0.5) or 0.5)
controller.blink(times=max(1, times), delay=max(0.05, delay))
return jsonify({"status": "success", "message": f"Мигание {times} раз"})
return jsonify({"status": "error", "message": "Неверная команда"}), 400
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@bp.post("/set_mixing_timer")
@require_auth_or_paired_terminal
def set_mixing_timer():
data = request.get_json(silent=True) or {}
with _runtime._lock:
_runtime.mixing_timer_active = bool(data.get("active", False))
return jsonify({"status": "success"})
@bp.get("/get_mixing_timer")
@require_auth_or_paired_terminal
def get_mixing_timer():
with _runtime._lock:
active = _runtime.mixing_timer_active
return jsonify({"active": active})
def _kg_int(value: float) -> int:
"""Целые килограммы для API и UI."""
return int(round(float(value)))
@bp.get("/weight_display_data")
@require_paired_terminal
def weight_display_data():
snap = _runtime.snapshot()
recipe = _get_active_recipe(snap["current_recipe_id"])
current_weight = _current_weight()
if recipe is None:
return jsonify(
{
"status": "no_recipe",
"component_name": "Текущий вес",
"remaining_weight": 0,
"current_loaded": _kg_int(current_weight),
"total_component": 0,
"total_mixture": _kg_int(current_weight),
"recipe_name": "",
"current_index": 0,
"total_components": 0,
"show_reset_button": False,
"show_nav_buttons": False,
"is_mixing_mode": snap["is_mixing_mode"],
"mixing_timer_active": snap["mixing_timer_active"],
"next_component_name": None,
}
)
ingredients = _loading_ingredient_rows(recipe)
total_components = len(ingredients)
if total_components == 0:
return jsonify({"status": "error", "message": "Нет активных компонентов"}), 400
idx = min(snap["current_component_index"], total_components - 1)
current_ingredient = ingredients[idx]
current_loaded = max(0.0, current_weight - snap["weight_at_current_component_start"])
total_component_weight = float(current_ingredient.get("amount") or 0)
total_mixture_weight = sum(float(i.get("amount") or 0) for i in ingredients)
remaining = max(0.0, total_component_weight - current_loaded)
next_name = ingredients[idx + 1]["name"] if idx < total_components - 1 else None
return jsonify(
{
"status": "active",
"component_name": current_ingredient["name"],
"remaining_weight": _kg_int(remaining),
"current_loaded": _kg_int(current_loaded),
"total_component": _kg_int(total_component_weight),
"total_mixture": _kg_int(total_mixture_weight),
"recipe_name": recipe.name,
"current_index": idx,
"total_components": total_components,
"show_reset_button": current_loaded > 0,
"show_nav_buttons": total_components > 1,
"is_mixing_mode": snap["is_mixing_mode"],
"mixing_timer_active": snap["mixing_timer_active"],
"next_component_name": next_name,
}
)
@bp.get("/get_current_recipe")
@require_auth
def get_current_recipe():
snap = _runtime.snapshot()
recipe = _get_active_recipe(snap["current_recipe_id"])
if recipe is None:
return jsonify({"status": "inactive", "message": "Рецепт не выбран"})
ingredients = _loading_ingredient_rows(recipe)
return jsonify(
{
"status": "active",
"recipe_id": recipe.id,
"name": recipe.name,
"ingredients": [
{"name": ingredient["name"], "amount": float(ingredient.get("amount") or 0)}
for ingredient in ingredients
],
"total_weight": sum(float(i.get("amount") or 0) for i in ingredients),
}
)
@bp.get("/current_loading_state")
@require_auth
def current_loading_state():
snap = _runtime.snapshot()
recipe = _get_active_recipe(snap["current_recipe_id"])
if recipe is None:
return jsonify({"status": "inactive", "message": "Рецепт не выбран"})
ingredients = _loading_ingredient_rows(recipe)
idx = min(snap["current_component_index"], max(len(ingredients) - 1, 0))
current_ingredient = ingredients[idx] if ingredients else None
current_weight = _current_weight()
loaded = max(0.0, current_weight - snap["weight_at_current_component_start"])
target = float(current_ingredient.get("amount") or 0) if current_ingredient else 0.0
remaining = max(0.0, target - loaded)
return jsonify(
{
"status": "active",
"recipe_id": recipe.id,
"recipe_name": recipe.name,
"current_index": idx,
"current_component": {
"name": current_ingredient["name"] if current_ingredient else "Не выбран",
"target_weight": target,
"current_weight": current_weight,
"current_loaded": loaded,
"remaining_weight": remaining,
},
"total_mixture_weight": sum(float(i.get("amount") or 0) for i in ingredients),
}
)
@bp.post("/set_current_recipe")
@require_auth_or_paired_terminal
def set_current_recipe():
data = request.get_json(silent=True) or {}
recipe_id = data.get("recipe_id")
if not recipe_id:
with _runtime._lock:
_runtime.current_recipe_id = None
_runtime.current_component_index = 0
_runtime.weight_at_current_component_start = 0.0
return jsonify({"status": "cleared"})
recipe = _get_active_recipe(str(recipe_id))
if recipe is None:
return jsonify({"status": "error", "message": "Рецепт не найден или удален"}), 404
with _runtime._lock:
_runtime.current_recipe_id = recipe.id
_runtime.current_component_index = 0
_runtime.weight_at_current_component_start = _current_weight()
return jsonify({"status": "ok"})
@bp.post("/sync_loading_state")
@require_auth_or_paired_terminal
def sync_loading_state():
data = request.get_json(silent=True) or {}
with _runtime._lock:
if data.get("recipe_id"):
_runtime.current_recipe_id = str(data["recipe_id"])
_runtime.current_component_index = int(data.get("component_index", 0) or 0)
_runtime.weight_at_current_component_start = float(
data.get("component_start_weight", 0) or 0
)
return jsonify({"status": "success"})
@bp.post("/update_loading_state")
@require_auth_or_paired_terminal
def update_loading_state():
return jsonify({"status": "success"})
@bp.post("/navigate_component")
@require_auth_or_paired_terminal
def navigate_component():
data = request.get_json(silent=True) or {}
direction = data.get("direction")
with _runtime._lock:
recipe = _get_active_recipe(_runtime.current_recipe_id)
if recipe is None:
return jsonify({"status": "error", "message": "Рецепт не выбран"}), 400
total = len(_loading_ingredient_rows(recipe))
if total == 0:
return jsonify({"status": "error", "message": "Нет активных компонентов"}), 400
if direction == "next" and _runtime.current_component_index < total - 1:
_runtime.current_component_index += 1
_runtime.weight_at_current_component_start = _current_weight()
elif direction == "prev" and _runtime.current_component_index > 0:
_runtime.current_component_index -= 1
_runtime.weight_at_current_component_start = _current_weight()
else:
return jsonify({"status": "error", "message": "Невозможно переключить"}), 400
return jsonify({"status": "ok", "index": _runtime.current_component_index})
@bp.post("/reset_component")
@require_auth_or_paired_terminal
def reset_component():
with _runtime._lock:
if _runtime.current_recipe_id is None:
return jsonify({"status": "error", "message": "Рецепт не выбран"}), 400
_runtime.weight_at_current_component_start = _current_weight()
return jsonify({"status": "ok"})
@bp.post("/send_navigation_command")
@require_paired_terminal
def send_navigation_command():
data = request.get_json(silent=True) or {}
command = data.get("command")
if command not in {"prev", "next"}:
return jsonify({"status": "error", "message": "Неверная команда"}), 400
with _runtime._lock:
_runtime.navigation_commands_queue.append({"command": command, "timestamp": time.time()})
if len(_runtime.navigation_commands_queue) > 10:
_runtime.navigation_commands_queue = _runtime.navigation_commands_queue[-10:]
return jsonify({"status": "success", "message": f"Команда {command} отправлена"})
@bp.get("/get_navigation_commands")
@require_auth_or_paired_terminal
def get_navigation_commands():
with _runtime._lock:
commands = list(_runtime.navigation_commands_queue)
_runtime.navigation_commands_queue.clear()
return jsonify({"status": "success", "commands": commands})
@bp.post("/set_role")
@require_auth
def set_role():
data = request.get_json(silent=True) or {}
role = data.get("role")
dispenser_name = (data.get("dispenser_name") or "").strip()
if role not in {"zootechnician", "dispenser"}:
return jsonify({"status": "error", "message": "Неверная роль"}), 400
session["role"] = role
if role == "dispenser":
session["dispenser_name"] = dispenser_name
else:
session.pop("dispenser_name", None)
return jsonify({"status": "ok", "role": role, "dispenser_name": dispenser_name})
@bp.get("/mac_info")
@require_auth
def mac_info():
raw = f"{uuid.getnode():012x}"
mac = ":".join(raw[i : i + 2] for i in range(0, 12, 2))
lock_enabled = bool(current_app.config.get("MAC_LOCK_ENABLED", False))
allowed = current_app.config.get("ALLOWED_MAC_ADDRESSES", []) or []
return jsonify(
{
"current_mac": mac,
"is_authorized": (not lock_enabled) or (mac in allowed),
"mac_lock_enabled": lock_enabled,
"allowed_mac_addresses": allowed,
}
)
def _model_by_entity_type(entity_type: str):
model_map = {
"components": Component,
"recipes": Recipe,
"ingredients": Ingredient,
"unloading_groups": UnloadingGroup,
"feed_dispensers": FeedDispenser,
"feed_mixers": FeedMixer,
"feeding_periods": FeedingPeriod,
"feeding_locations": FeedingLocation,
"feeding_points": FeedingPoint,
"trips": Trip,
"period_recipes": PeriodRecipe,
"loading_reports": LoadingReport,
"loading_report_components": LoadingReportComponent,
"component_loading_times": ComponentLoadingTime,
"unloading_reports": UnloadingReport,
"unloading_report_groups": UnloadingReportGroup,
}
return model_map.get(entity_type)
def _resolve_entity(model, entity_id: str):
if model is PeriodRecipe:
parts = entity_id.split(":", 1)
if len(parts) != 2:
return None
return db.session.execute(
select(PeriodRecipe).where(
PeriodRecipe.period_id == parts[0],
PeriodRecipe.recipe_id == parts[1],
PeriodRecipe.is_deleted.is_(False),
)
).scalar_one_or_none()
return db.session.get(model, entity_id)
@bp.delete("/<string:entity_type>/<string:entity_id>/soft_delete")
@require_auth
def soft_delete_entity(entity_type: str, entity_id: str):
model = _model_by_entity_type(entity_type)
if model is None:
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
entity = _resolve_entity(model, entity_id)
if entity is None:
return _error("Объект не найден", 404)
if not hasattr(entity, "is_deleted"):
return _error("Мягкое удаление не поддерживается", 400)
if entity.is_deleted:
return _error("Объект уже удален", 400)
deleted_by = (request.get_json(silent=True) or {}).get("deleted_by") or "system"
entity.soft_delete(deleted_by_user=deleted_by)
db.session.commit()
return jsonify(
{
"success": True,
"message": f"{entity_type} успешно удален",
"deleted_at": entity.deleted_at.isoformat() if entity.deleted_at else None,
"deleted_by": entity.deleted_by,
}
)
@bp.post("/<string:entity_type>/<string:entity_id>/restore")
@require_auth
def restore_entity(entity_type: str, entity_id: str):
model = _model_by_entity_type(entity_type)
if model is None:
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
entity = _resolve_entity(model, entity_id)
if entity is None:
return _error("Объект не найден", 404)
if not hasattr(entity, "is_deleted"):
return _error("Восстановление не поддерживается", 400)
if not entity.is_deleted:
return _error("Объект не удален", 400)
entity.is_deleted = False
entity.deleted_at = None
entity.deleted_by = None
db.session.commit()
return jsonify({"success": True, "message": f"{entity_type} успешно восстановлен"})
@bp.get("/<string:entity_type>/deleted")
@require_auth
def deleted_entities(entity_type: str):
model = _model_by_entity_type(entity_type)
if model is None:
return _error(f"Неизвестный тип сущности: {entity_type}", 400)
if not hasattr(model, "is_deleted"):
return jsonify([])
rows = db.session.execute(select(model).where(model.is_deleted.is_(True))).scalars().all()
payload = []
for r in rows:
payload.append(
{
"id": getattr(r, "id", None),
"name": getattr(r, "name", "N/A"),
"is_deleted": getattr(r, "is_deleted", False),
"deleted_at": r.deleted_at.isoformat() if getattr(r, "deleted_at", None) else None,
"deleted_by": getattr(r, "deleted_by", None),
}
)
return jsonify(payload)
@bp.get("/updates/check")
@require_auth_or_paired_terminal
def updates_check():
return jsonify(force_check_updates())
@bp.post("/updates/install")
@require_auth_or_paired_terminal
def updates_install():
body, code = install_pending_update()
return jsonify(body), code
@bp.get("/updates/status")
@require_auth_or_paired_terminal
def updates_status():
return jsonify(build_updates_status_payload())
@bp.get("/health")
def health_check():
from app.services.update_health import build_health_payload
payload = build_health_payload(current_app)
code = 200 if payload.get("ok") else 503
return jsonify(payload), code
@bp.get("/server/info")
def server_info():
ver = current_app.config.get("SYNC_CLIENT_VERSION", "1.0.0")
is_master = bool(current_app.config.get("SYNC_CLIENT_IS_MASTER", True))
return jsonify(
{
"system": "wesp",
"version": ver,
"role": "server",
"status": "active",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"api_version": "1.0",
"is_master": is_master,
}
)
@bp.get("/config/update_server_url")
@require_auth
def update_server_url():
new_url = (request.args.get("url") or "").strip()
if not new_url:
return jsonify({"status": "error", "message": "Не указан параметр url"}), 400
if not (new_url.startswith("http://") or new_url.startswith("https://")):
return (
jsonify(
{
"status": "error",
"message": "url должен начинаться с http:// или https://",
}
),
400,
)
normalized = new_url.rstrip("/") + "/"
write_sync_client_state(
{
"server_url": normalized,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
)
return jsonify(
{
"status": "success",
"message": "URL сервера обновлён",
"server_url": normalized,
}
)