@@ -0,0 +1,440 @@
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user