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/") @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("///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("///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("//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, } )