584 lines
21 KiB
Python
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,
|
|
}
|
|
)
|
|
|