441 lines
18 KiB
Python
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
|
|
|