Перейти к содержанию

Логика дозирования

Контроллер управляет тремя независимыми каналами дозирования моющего средства в моечную машину. Каждый канал = одна задача FreeRTOS, один выходной пин на реле помпы и три входных пина (RUN от мойки, LEV от датчика уровня, общий тик 20 мс).

Цель: подать моющее в пропорции к длительности фазы мойки. Управление дискретное (реле on/off), плавное среднее достигается ШИМ-алгоритмом Bresenham.


1. Сигналы и пины

Источник истины: channels[] в src/dosator.cpp.

Сигнал Тип Канал 0 Канал 1 Канал 2 Описание
RUN INPUT GPIO 34 GPIO 35 GPIO 36 Внешний сигнал от мойки "идёт мойка"
LEV INPUT GPIO 39 GPIO 25 GPIO 26 Датчик уровня (AC = пусто, тишина = есть; §6)
DOZ OUTPUT GPIO 27 GPIO 12 GPIO 13 Управление катушкой реле помпы дозатора

Имена пинов в config.h: RUN1L_PIN…RUN3L_PIN, аналогично LEV/DOZ.


2. Параметры алгоритма

Параметр Значение Источник
DOSATOR_PERIOD_MS 20 мс Длина одного тика ШИМ
DOSATOR_WARMUP_PERIODS 2 Soft-start: первые 2 тика всегда HIGH (40 мс)
DOSATOR_RUN_TIMEOUT_MS 40 мс Таймаут ожидания фронта RUN-ISR (auto-режим)
DOSATOR_ISR_DEBOUNCE_MS 15 мс Антидребезг ISR на пине RUN
DOSATOR_LEV_DEBOUNCE_TICKS 3 Symmetric debounce LEV (см. §6)
DOSATOR_LEV_AC_WINDOW_MS 60 мс Окно детекции AC-фронтов LEV
proportion 0…99 NVS, на канал (см. nvs-reference.md)
window_ms 100…60000 (default 2000) NVS, окно цикла дозирования

Из window_ms и proportion вычисляется доля времени HIGH: duty = proportion / (window_ms / 20) = proportion / window_periods.

При дефолте window=2000 мс это даёт window_periods=100 → пропорция численно равна проценту: prop=22 → 22% duty.


3. Конечный автомат канала

stateDiagram-v2
    [*] --> IDLE
    IDLE --> DOSING: RUN=HIGH (фронт ISR)
    IDLE --> DOSING: forceActive=true (API)
    DOSING --> IDLE: RUN-timeout > 40мс (auto)
    DOSING --> IDLE: forceActive=false (API)
    DOSING --> IDLE: proportion=0

Триггеры перехода

IDLE → DOSING: - В авто-режиме: ISR на пине RUN срабатывает по любому фронту (HIGH или LOW — реле мойки замыкает). ISR делает vTaskNotifyGive → задача просыпается, проверяет proportion > 0 и переходит в DOSING. - В принудит. режиме: API POST /api/dosator/dispense ставит флаг forceActive = true. Задача в каждой итерации проверяет флаг.

DOSING → IDLE: - В авто-режиме: ulTaskNotifyTake с таймаутом 40 мс. Если за 40 мс не пришёл ISR-нотификейшен — мойка завершилась, переход в IDLE. - В принудит. режиме: API POST /api/dosator/stop сбрасывает forceActive. - Если proportion = 0 — канал немедленно возвращается в IDLE.


4. Режимы дозирования

У каждого канала есть режим (mode, NVS-ключ mode_<ch>, см. nvs-reference.md):

  • Пропорциональный (mode = 0, по умолчанию) — Bresenham размазывает proportion ON-тиков равномерно по окну: много коротких импульсов.
  • Импульсный (mode = 1) — те же proportion тиков подряд в начале окна: один сплошной импульс, затем пауза до конца окна.

Суммарное время подачи за окно в обоих режимах одинаковое (proportion × 20 мс); отличается только распределение.

Импульсный (src/dosator.cpp, ветка PULSED):

bool on = (c.winPos < eff);          // первые eff тиков окна — HIGH
digitalWrite(c.pinDoz, on ? HIGH : LOW);
c.winPos++;
if (c.winPos >= c.windowPeriods()) c.winPos = 0;
Пример: окно 2000 мс (100 периодов), proportion=25 → первые 500 мс сплошной HIGH, остальные 1500 мс LOW. Soft-start (warmup) в этом режиме не нужен — начало окна и так непрерывный HIGH.

Пропорциональный режим описан ниже.


4.1. Bresenham-ШИМ (пропорциональный режим)

Алгоритм даёт точное соотношение HIGH-тиков к окну без таймера, через накопительный аккумулятор. Реализован прямо в основном цикле задачи (src/dosator.cpp, ветка PROPORTIONAL):

c.acc += eff;                              // eff = proportion (или 0 если !lev)
if (c.acc >= c.windowPeriods()) {          // windowPeriods = window_ms / 20
    c.acc -= c.windowPeriods();
    digitalWrite(c.pinDoz, HIGH);
    c.pulseCount++;
} else {
    digitalWrite(c.pinDoz, LOW);
}

Каждые 20 мс к аккумулятору прибавляется proportion. Когда он переполняет окно — на этом тике пин HIGH (20 мс), и накопитель уменьшается на размер окна. Иначе — LOW.

Пример: proportion=22, window=2000мс (windowPeriods=100)

тик время acc до acc+22 HIGH? acc после
1 0…20 мс 0 22 22
2 20…40 22 44 44
3 40…60 44 66 66
4 60…80 66 88 88
5 80…100 88 110 10
6 100…120 10 32 32
7 120…140 32 54 54
8 140…160 54 76 76
9 160…180 76 98 98
10 180…200 98 120 20
100 1980…2000 (последний тик окна) 22-й ✓

За 100 тиков (2 секунды) — ровно 22 HIGH-тика, итого 440 мс HIGH из 2000 мс окна = 22% duty, равномерно распределённых.

Timing diagram (ASCII)

prop=22, window=100 (2000 мс):

тик  1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  …
      ___                                 ___
     |   |_______________________________|   |____________________…
DOZ
      ↑ HIGH в тиках, где acc выходит за окно
      └─ 20 мс HIGH ─┘   └─ ~80 мс LOW ─┘

При proportion=50 — каждый второй тик HIGH (50% duty):

prop=50, window=100:
      ___     ___     ___     ___
     |   |___|   |___|   |___|   |___…

5. Soft-start (warmup)

Первые DOSATOR_WARMUP_PERIODS = 2 тика после IDLE→DOSING пин всегда HIGH независимо от пропорции. Это гарантированный 40-мс импульс на старте, который пробивает катушку реле / открывает клапан, чтобы последующие короткие импульсы (при низкой пропорции) уже точно работали.

src/dosator.cpp:156…162:

if (c.warmup > 0) {
    digitalWrite(c.pinDoz, (eff > 0) ? HIGH : LOW);
    if (eff > 0) c.pulseCount++;
    c.warmup--;
    continue;  // не входим в Bresenham
}


6. Датчик уровня (LEV) — детекция и сигнализация

LEV — вход с датчика концентрата в баке. Физика инвертирована относительно RUN: датчик выдаёт 50 Гц AC на пин когда бак пуст; если концентрат есть, линия молчит.

Реализовано через ISR (RISING) аналогично RUN, но без нотификации task'а — ISR только обновляет c.levLastEdgeTick. Task в каждом тике смотрит, был ли фронт за DOSATOR_LEV_AC_WINDOW_MS (60 мс):

bool acPresent = (now - c.levLastEdgeTick) < pdMS_TO_TICKS(60);
bool rawConcPresent = !acPresent;  // инверсия: AC = нет концентрата

После symmetric debounce (DOSATOR_LEV_DEBOUNCE_TICKS = 3) flip believed-состояния c.levState вызывает alarmRaise/alarmClear для AlarmId::LEV{1,2,3}_LOW.

Поведение при низком уровне

Дозатор продолжает работать с фактической пропорцией — eff не зануляется. Зануление давало ложное чувство безопасности (пневматика мойки всё равно гонит воду); вместо этого поднимаем alarm и оставляем решение оператору.

Уведомление

alarm_manager маршрутизирует через EventCategory::DISPENSER во все включённые интеграции (mail/maxbot/telegram). Cooldown 15 мин — повторные raise'ы внутри окна не спамят.

UI: в карточке дозатора при c.lev=false рендерится alert-плашка «Низкий уровень концентрата» + бейдж «Уровень: low» в строке индикаторов.

Стенд (AWDC_DOSATOR_IGNORE_LEV)

На макете без подключённых датчиков LEV-ISR не вешается, detectLev() не вызывается, alarm не поднимается. Снять флаг перед боевой сборкой.


7. Auto vs Force режим (внутри цикла задачи)

flowchart TD
    A[wdtFeed] --> B{forceActive?}
    B -- да --> C[vTaskDelay 20мс]
    B -- нет --> D[ulTaskNotifyTake<br/>таймаут 40мс]
    C --> E[notified=1]
    D -- нет нотификации --> F{state == DOSING?}
    F -- да --> G[pinDoz=LOW<br/>postStopEvent<br/>state=IDLE]
    F -- нет --> A
    G --> A
    D -- есть нотификация --> E
    E --> H{proportion=0?}
    H -- да --> I[pinDoz=LOW<br/>state=IDLE]
    I --> A
    H -- нет --> J{state == IDLE?}
    J -- да --> K[postStartEvent<br/>warmup=2<br/>state=DOSING]
    J -- нет --> L[Bresenham + LEV]
    K --> L
    L --> A

Auto: опирается на ISR pinRun — таймаут 40 мс между фронтами RUN означает, что мойка остановилась. Окно ISR откалибровано под реле мойки (типичный фронт 5…20 мс).

Force: игнорирует RUN-ISR, гонит цикл искусственно через vTaskDelay. LEV всё равно проверяется — без концентрата дозатор не нальёт.


8. Учёт наработки (runtime)

Счётчики канала в секундах хранятся в I2C-EEPROM M24C16 (схема v3, две зоны). По каждому каналу две метрики — RUN-наработка (время сигнала RUNxL) и время насоса (outN=HIGH) — в двух разрезах: абсолютная (lifetime) и относительная (с последнего сброса на ТО). Относительная = абсолютная − baseline. Подробности раскладки EEPROM — memory-map.md.

Тики runtime:

  • Каждые 50 периодов (50 × 20 мс = 1 сек) RUN — eepromCountersOnPeriod(ch) инкрементирует RAM-счётчик session_s.
  • Каждый период с outN=HIGH — eepromCountersOnPulse(ch); дробный аккумулятор: 50 таких периодов = +1 с в session_pump_s.
  • Каждые 3000 периодов (60 сек) — eepromCountersNotifyFlush() будит flush-task, которая пишет head-слот кольца Zone A (абсолют).
  • При DOSING → IDLEeepromCountersOnStop() добавляет session_s к abs_runtime, session_pump_s к abs_pump, обнуляет сессию. Финальный flush — в фоновой task.
  • Сброс на ТО (POST /api/statistics/reset) — пишет baseline в Zone B: относительные счётчики обнуляются, абсолютные сохраняются.

Уведомление о сервисном обслуживании. При завершении дозирования (postStopEventdosatorCheckService) контроллер сравнивает относительную наработку канала (eepromCountersGetRuntimeSince, с последнего сервисного сброса) с интервалом сервиса notify_thr (per channel, default 720000 сек = 200 ч). При достижении порога и notify_enabled — однократно шлётся уведомление через Notifier («Требуется сервис, канал N»); флаг notify_sent гасит повтор. Сервисный сброс (POST /api/statistics/reset) обнуляет notify_sent — уведомление взводится на следующий интервал. Интервал и галочка уведомления настраиваются: Система → Дозаторы.


9. Аварийная остановка

dosatorEmergencyStop() — синхронно сбрасывает все три пина DOZ в LOW без остановки самих задач. Вызывается из глобального обработчика аварий (например, при критической ошибке в EEPROM-флэше или ручном emergency-stop через API/HMI).


10. API быстрый референс

Метод Эндпоинт Действие
GET /api/dosator/status Состояние всех каналов
POST /api/dosator/config Изменить proportion / window_ms
POST /api/dosator/dispense Принудит. старт (forceActive=true)
POST /api/dosator/stop Принудит. стоп (forceActive=false)
GET /api/statistics Наработка: абсолютная + с ТО
POST /api/statistics/reset Сброс на ТО канала (ADMIN)

Детали запросов/ответов: api/dosator.md.