Files
site/WESP_REL/app/services/hardware/scales_reader.py
T
2026-07-17 12:57:18 +03:00

441 lines
18 KiB
Python

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