import threading import time from collections import deque from queue import Empty, Queue from statistics import median from typing import Deque, Dict, Optional from .hx711_wrapper import HX711UnavailableError, HX711Wrapper def _scale_hardware_platform() -> bool: try: from app.services.hardware_settings_service import is_scale_hardware_platform return is_scale_hardware_platform() except Exception: return False class ScalesReader: """Thread-safe scales reader with configurable filter pipeline.""" def __init__( self, simulation_mode: bool, read_interval: float, samples_per_read: int, sample_history_size: int, median_window: int, avg_window: int, weights, smoothing_factor: float, round_to_step: float, tare_raw_window: int, hx711_dout_pin: int, hx711_pd_sck_pin: int, ): self.simulation_mode = simulation_mode self.read_interval = read_interval self.samples_per_read = samples_per_read self.sample_history_size = sample_history_size self.median_window = max(1, median_window) self.avg_window = max(1, avg_window) self.weights = list(weights) if weights else [0.2, 0.3, 0.5] self.smoothing_factor = smoothing_factor self.round_to_step = round_to_step self.tare_raw_window = max(1, tare_raw_window) self._stop_event = threading.Event() self._queue: Queue[float] = Queue(maxsize=max(200, sample_history_size)) self._lock = threading.Lock() self._reader_thread: Optional[threading.Thread] = None self._processor_thread: Optional[threading.Thread] = None self._raw_history: Deque[float] = deque(maxlen=sample_history_size) self._filtered_history: Deque[float] = deque(maxlen=sample_history_size) self._weight_history: Deque[float] = deque(maxlen=sample_history_size) self._current_weight = 0.0 self._tare_raw = 0.0 self._counts_per_kg = 1.0 self._sim_weight = 0.0 self._sim_weight_kg = 0.0 self._last_hx711_error: Optional[str] = None self._hx711_error_active = False self._error_streak = 0 self._last_success_at: Optional[float] = None self._flask_app = None self._scale_hardware_platform = _scale_hardware_platform() self._hx711 = HX711Wrapper( simulation_mode=not self._hx711_real_hardware(), dout_pin=hx711_dout_pin, pd_sck_pin=hx711_pd_sck_pin, ) @classmethod def from_config(cls, config: Dict): return cls( simulation_mode=bool(config.get("SIMULATION_MODE", False)), read_interval=float(config.get("READ_INTERVAL", 0.05)), samples_per_read=int(config.get("SAMPLES_PER_READ", 3)), sample_history_size=int(config.get("SAMPLE_HISTORY_SIZE", 200)), median_window=int(config.get("MEDIAN_WINDOW", 6)), avg_window=int(config.get("AVG_WINDOW", 4)), weights=config.get("WEIGHTS", (0.2, 0.3, 0.5)), smoothing_factor=float(config.get("SMOOTHING_FACTOR", 0.3)), round_to_step=float(config.get("ROUND_TO_STEP", 1)), tare_raw_window=int(config.get("TARE_RAW_WINDOW", 2)), hx711_dout_pin=int(config.get("HX711_DOUT_PIN", 2)), hx711_pd_sck_pin=int(config.get("HX711_PD_SCK_PIN", 3)), ) def start(self) -> None: with self._lock: if self._reader_thread and self._reader_thread.is_alive(): return self._load_runtime_settings_locked() self._stop_event.clear() self._reader_thread = threading.Thread( target=self._reader_loop, daemon=True, name="scales-reader" ) self._processor_thread = threading.Thread( target=self._processor_loop, daemon=True, name="scales-processor" ) self._reader_thread.start() self._processor_thread.start() def stop(self) -> None: self._stop_event.set() if self._reader_thread and self._reader_thread.is_alive(): self._reader_thread.join(timeout=2) if self._processor_thread and self._processor_thread.is_alive(): self._processor_thread.join(timeout=2) def tare(self) -> float: with self._lock: if not self._raw_history: raise ValueError("Недостаточно данных для обнуления") window = list(self._raw_history)[-self.tare_raw_window :] self._tare_raw = float(sum(window) / max(1, len(window))) self._persist_hardware_settings_locked() return self._tare_raw def calibrate(self, calibration_factor: float) -> float: if calibration_factor <= 0: raise ValueError("calibration_factor must be positive") with self._lock: self._counts_per_kg = calibration_factor self._persist_hardware_settings_locked() return self._counts_per_kg def set_counts_per_kg(self, counts_per_kg: float) -> float: return self.calibrate(counts_per_kg) def get_current_weight(self) -> int: with self._lock: return int(self._current_weight) def get_calibration_factor(self) -> float: with self._lock: return float(self._counts_per_kg) def get_counts_per_kg(self) -> float: return self.get_calibration_factor() def bind_flask_app(self, app) -> None: """Для журнала периферии из фонового потока чтения.""" self._flask_app = app def get_last_hx711_error(self) -> Optional[str]: with self._lock: return self._last_hx711_error def get_simulation_state(self) -> dict: with self._lock: return { "simulation_mode": bool(self.simulation_mode), "simulation_weight_kg": float(self._sim_weight_kg), "scale_hardware_platform": bool(self._scale_hardware_platform), } def _hx711_real_hardware(self) -> bool: return bool(not self.simulation_mode and self._scale_hardware_platform) def _should_poll_scales(self) -> bool: return bool(self.simulation_mode or self._scale_hardware_platform) def _sync_hx711_wrapper_mode(self) -> None: # На не-ARM без симуляции драйвер не поднимаем — нет GPIO/HX711. self._hx711.apply_simulation_mode(not self._hx711_real_hardware()) def set_simulation_mode(self, enabled: bool) -> dict: enabled = bool(enabled) with self._lock: self.simulation_mode = enabled self._sync_hx711_wrapper_mode() if enabled: self._last_hx711_error = None self._hx711_error_active = False self._error_streak = 0 self._apply_simulated_weight_locked() else: self._last_hx711_error = None self._hx711_error_active = False self._error_streak = 0 self._persist_hardware_settings_locked() self._sync_gpio_simulation_mode(enabled) return self.get_simulation_state() def set_simulation_weight(self, weight_kg: float) -> int: with self._lock: if not self.simulation_mode: raise ValueError("Симуляция выключена — включите её перед изменением веса") self._sim_weight_kg = max(0.0, float(weight_kg)) self._apply_simulated_weight_locked() self._persist_hardware_settings_locked() return int(self._sim_weight_kg) def adjust_simulation_weight(self, delta_kg: int) -> int: with self._lock: if not self.simulation_mode: raise ValueError("Симуляция выключена — включите её перед изменением веса") self._sim_weight_kg = max(0.0, self._sim_weight_kg + int(delta_kg)) self._apply_simulated_weight_locked() self._persist_hardware_settings_locked() return int(self._sim_weight_kg) @staticmethod def _sync_gpio_simulation_mode(enabled: bool) -> None: try: from .gpio_controller import GPIOController inst = GPIOController._instance if inst is not None: inst.simulation_mode = bool(enabled) except Exception: pass def _apply_simulated_weight_locked(self) -> None: cpk = self._counts_per_kg if self._counts_per_kg > 0 else 1.0 raw = self._tare_raw + self._sim_weight_kg * cpk self._sim_weight = raw self._current_weight = float(int(self._sim_weight_kg)) self._raw_history.append(raw) self._weight_history.append(float(self._current_weight)) def get_scale_debug_snapshot(self) -> dict: with self._lock: raw = list(self._raw_history) tare = float(self._tare_raw) cpk = float(self._counts_per_kg) streak = int(self._error_streak) last_ok = self._last_success_at last_raw = float(raw[-1]) if raw else None raw_min = float(min(raw)) if raw else None raw_max = float(max(raw)) if raw else None return { "counts_per_kg": cpk, "tare_raw": tare, "raw_history_len": len(raw), "last_raw": last_raw, "raw_min_recent": raw_min, "raw_max_recent": raw_max, "error_streak": streak, "last_success_at": last_ok, } def get_scale_health(self) -> dict: """Для API: есть ли достоверные отсчёты с датчика (при SIMULATION_MODE=0).""" with self._lock: sim = self.simulation_mode driver_up = getattr(self._hx711, "_device", None) is not None err = self._last_hx711_error has_samples = len(self._raw_history) > 0 if sim: return {"simulation_mode": True, "hx711_ok": True, "hx711_error": None} if not self._scale_hardware_platform: return { "simulation_mode": False, "hx711_ok": False, "hx711_skipped": True, "hx711_error": None, } if not driver_up: return { "simulation_mode": False, "hx711_ok": False, "hx711_error": err or "HX711 не инициализирован", } if err: return {"simulation_mode": False, "hx711_ok": False, "hx711_error": err} if not has_samples: return { "simulation_mode": False, "hx711_ok": False, "hx711_error": "Ожидание первого отсчёта с датчика", } return {"simulation_mode": False, "hx711_ok": True, "hx711_error": None} def average_recent_raw(self, window: Optional[int] = None) -> float: with self._lock: if not self._raw_history: raise ValueError("Нет сырых данных для расчёта") size = max(1, int(window or self.tare_raw_window)) values = list(self._raw_history)[-size:] return float(sum(values) / max(1, len(values))) def calibration_debug_payload(self) -> dict: """Снимок сырых отсчётов для шаблона калибровки (как в легаси weight_data).""" with self._lock: raw = list(self._raw_history) weights = list(self._weight_history) return { "last_50_raw": raw[-50:], "readings_history": raw[-20:], "weight_history": weights[-20:], } def _load_runtime_settings_locked(self) -> None: try: from app.models import HardwareSetting from app import db row = db.session.get(HardwareSetting, 1) if row is None: row = HardwareSetting( id=1, counts_per_kg=float(self._counts_per_kg or 1.0), tare_raw=float(self._tare_raw or 0.0), simulation_mode=bool(self.simulation_mode), simulation_weight_kg=float(self._sim_weight_kg or 0.0), ) db.session.add(row) db.session.commit() else: self._counts_per_kg = float(row.counts_per_kg or 1.0) self._tare_raw = float(row.tare_raw or 0.0) self._sim_weight_kg = max(0.0, float(row.simulation_weight_kg or 0.0)) sim = bool(row.simulation_mode) self.simulation_mode = sim self._sync_hx711_wrapper_mode() if sim: self._apply_simulated_weight_locked() except Exception: self._counts_per_kg = 1.0 self._tare_raw = 0.0 self._sim_weight_kg = 0.0 def _persist_hardware_settings_locked(self) -> None: from app.models import HardwareSetting from app import db row = db.session.get(HardwareSetting, 1) if row is None: row = HardwareSetting(id=1) db.session.add(row) row.counts_per_kg = float(self._counts_per_kg) row.tare_raw = float(self._tare_raw) row.simulation_mode = bool(self.simulation_mode) row.simulation_weight_kg = float(self._sim_weight_kg) db.session.commit() def _reader_loop(self) -> None: while not self._stop_event.is_set(): if not self._should_poll_scales(): time.sleep(self.read_interval) continue for _ in range(self.samples_per_read): try: sample = self._read_raw_sample() except HX711UnavailableError as exc: msg = str(exc) with self._lock: self._last_hx711_error = msg self._error_streak += 1 was_active = self._hx711_error_active self._hx711_error_active = True self._emit_hx711_state(was_active=was_active, is_error=True, message=msg) continue with self._lock: was_active = self._hx711_error_active self._last_hx711_error = None self._hx711_error_active = False self._error_streak = 0 self._last_success_at = time.time() if was_active: self._emit_hx711_state(was_active=True, is_error=False, message="") try: self._queue.put(sample, timeout=0.05) except Exception: pass time.sleep(self.read_interval) def _processor_loop(self) -> None: while not self._stop_event.is_set(): try: raw = self._queue.get(timeout=0.2) except Empty: continue with self._lock: self._raw_history.append(raw) filtered = self._apply_filters_locked() counts_per_kg = self._counts_per_kg if self._counts_per_kg > 0 else 1.0 weight_kg = (filtered - self._tare_raw) / counts_per_kg if self.round_to_step > 0: weight_kg = round(weight_kg / self.round_to_step) * self.round_to_step else: weight_kg = round(weight_kg) # Целые килограммы; отрицательные значения допустимы (ниже нуля после tare). self._current_weight = float(int(round(weight_kg))) self._weight_history.append(float(self._current_weight)) def _emit_hx711_state(self, *, was_active: bool, is_error: bool, message: str) -> None: app = self._flask_app if app is None or self.simulation_mode or not self._scale_hardware_platform: return try: with app.app_context(): from app.services.admin_peripheral_monitor import ( notify_hx711_error, notify_hx711_recovered, ) if is_error: notify_hx711_error(app, message) elif was_active: notify_hx711_recovered(app) except Exception: pass def _read_raw_sample(self) -> float: if self.simulation_mode: cpk = self._counts_per_kg if self._counts_per_kg > 0 else 1.0 return self._tare_raw + self._sim_weight_kg * cpk return self._hx711.read_weight_sample() def _apply_filters_locked(self) -> float: if not self._raw_history: return 0.0 values = list(self._raw_history) median_slice = values[-self.median_window :] median_val = float(median(median_slice)) avg_slice = values[-self.avg_window :] avg_val = float(sum(avg_slice) / max(1, len(avg_slice))) weighted_slice = values[-len(self.weights) :] if len(weighted_slice) < len(self.weights): missing = len(self.weights) - len(weighted_slice) weighted_slice = [weighted_slice[0]] * missing + weighted_slice if weighted_slice else [0.0] * len(self.weights) weighted_val = sum(w * x for w, x in zip(self.weights, weighted_slice)) combined = (median_val + avg_val + weighted_val) / 3.0 prev = self._filtered_history[-1] if self._filtered_history else combined smoothed = (self.smoothing_factor * combined) + ((1.0 - self.smoothing_factor) * prev) self._filtered_history.append(smoothed) return smoothed