Логика дозирования¶
Контроллер управляет тремя независимыми каналами дозирования моющего средства в моечную машину. Каждый канал = одна задача 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 размазываетproportionON-тиков равномерно по окну: много коротких импульсов. - Импульсный (
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;
Пропорциональный режим описан ниже.
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):
5. Soft-start (warmup)¶
Первые DOSATOR_WARMUP_PERIODS = 2 тика после IDLE→DOSING пин всегда
HIGH независимо от пропорции. Это гарантированный 40-мс импульс на
старте, который пробивает катушку реле / открывает клапан, чтобы
последующие короткие импульсы (при низкой пропорции) уже точно работали.
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 → IDLE —
eepromCountersOnStop()добавляетsession_sкabs_runtime,session_pump_sкabs_pump, обнуляет сессию. Финальный flush — в фоновой task. - Сброс на ТО (
POST /api/statistics/reset) — пишет baseline в Zone B: относительные счётчики обнуляются, абсолютные сохраняются.
Уведомление о сервисном обслуживании. При завершении дозирования
(postStopEvent → dosatorCheckService) контроллер сравнивает
относительную наработку канала (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.