Troubleshooting¶
Типовые симптомы при работе с idryer-core, их причины и решения.
Перед чтением убедитесь, что у вас включены HAL-логи (idryer::hal::initArduinoHal(&Serial)) и в platformio.ini стоит -DCORE_DEBUG_LEVEL=3 или выше.
WiFi¶
Стейт-машина застряла в WifiConnecting¶
Симптомы: лог повторяется state: WifiConnecting, переход в Provisioning не наступает.
Возможные причины:
- Неверный SSID/пароль. Проверьте
WIFI_SSID/WIFI_PASSWORDвsecrets.h. После Improv credentials берутся из NVS — не изsecrets.h. - Сеть 5 GHz. ESP32 поддерживает только 2.4 GHz.
- Скрытая сеть или MAC-фильтр на роутере.
WiFi.begin()вызван доidryer::hal::initArduinoHal(...)— лог не пишется, но это не причина зависания, а просто слепота.
Что проверить:
WiFi подключается, но через 30–60 секунд отваливается¶
Обычно: слабый сигнал (RSSI < -80 dBm), питание ESP32-C3 от USB-хаба без отдельного 5V/1A, конфликт с FreeRTOS-задачами.
Логирование RSSI в product loop:
if (millis() - lastRssi > 30000) { lastRssi = millis(); HAL_LOG_INFO("WIFI", "RSSI: %d dBm", WiFi.RSSI()); }
Provisioning и claiming¶
Стейт-машина застряла в Provisioning¶
Симптомы: state: Provisioning без перехода в Registering или AwaitingClaim.
Причины:
- Неверный
IDRYER_API_BASEв build_flags. Должен бытьhttps://portal.idryer.org/api(production) илиhttps://staging.idryer.org/api(staging). - Нет TLS-сертификата (Let's Encrypt ISRG Root X1). Встроен в
root_ca.h, но при сборке безMQTT_USE_TLSHTTP-клиент тоже использует TLS — это нормально, корневой CA нужен и для HTTP API. - Время устройства не синхронизировано (TLS handshake требует валидной даты). Проверьте, что
configTime(...)вызывается вsetStateChangeCallbackпослеWifiConnecting(как в Storage Link).
Стейт-машина застряла в AwaitingClaim¶
Это нормальное состояние, пока пользователь не ввёл PIN в портале. PIN печатается в лог через setClaimPinCallback.
Если требуется автоматический claim (для standalone-устройств без UI):
После requestClaim() бэкенд выдаёт PIN, который пользователь должен ввести в портале.
seedSerialFromMac() сгенерировал серийник, но в портале ввели другой¶
Серийник, сохранённый в NVS, имеет приоритет над MAC-генерацией. seedSerialFromMac() пишет в NVS только если серийника там ещё нет. Чтобы сменить серийник, очистите NVS:
MQTT¶
Стейт-машина зашла в MqttConnecting, но не выходит в Online¶
Причины:
- Брокер недоступен. Production:
mqtt.idryer.org:8883, staging:staging.idryer.org:1884. MQTT_USE_TLS=1без корректного корневого CA — handshake падает молча.setBufferSize(16384)не применяется — размер MQTT-буфера вPubSubClientпо умолчанию 256 байт.MqttClientуже устанавливает 16384, но если вы используетеPubSubClientнапрямую — установите буфер сами.- Persistent session "залипла" на брокере с другим client ID. Очистите NVS и перепрошейте.
Команды от бэкенда не приходят¶
Проверьте подписку — MqttClient подписывается на idryer/{serial}/commands/# с QoS 1. Если подписка не удалась, в логе будет:
Проверьте, что setCommandHandler() вызван до runtime.begin() — иначе первая порция команд может пройти мимо.
PubSubClient отключается с интервалом ровно 60 секунд¶
Это keep-alive timeout. Возможно, ваш MQTT-loop не вызывается достаточно часто — s_runtime.loop() должен крутиться без длинных блокировок. Проверьте, что в loop() нет delay(>500ms) и нет блокирующих сетевых вызовов.
Команды и обработчики¶
commands/invoke приходит, но ActionDispatcher не вызывается¶
Если вы зарегистрировали setCommandHandler(), встроенный fallback на ActionDispatcher отключается. IdryerRuntime отдаёт всё (кроме ping) в ваш CommandHandler. В нём нужно явно вызвать s_dispatcher.handleInvoke(data) для команд invoke.
Шаблон:
static void handleCommand(const char* cmd, JsonObjectConst data) {
if (strcmp(cmd, "invoke") == 0) { s_dispatcher.handleInvoke(data); return; }
if (strcmp(cmd, "set") == 0) { s_dispatcher.handleSet(data); return; }
// ... продуктовые команды ...
}
commands/set принят, но конфиг не применился¶
ActionDispatcher::handleSet извлекает id и val и передаёт в зарегистрированный SetCallback. Проверьте:
dispatcher.setSetCallback(onSetCommand, nullptr)вызван вsetup().- В
onSetCommandдействительно вызываетсяs_profile.applyConfig(id, val). applyConfigвозвращаетtrueдля известныхid. Для неизвестных — возвращаетfalse, изменения игнорируются.
Telemetry¶
Telemetry не публикуется¶
idryer-core не публикует телеметрию автоматически. Это всегда делает product code.
Проверьте, что:
- В
loop()действительно вызываетсяpub.publishTelemetry(doc)(илиs_mqtt.publishTelemetry(doc), если LocalAccess не используется). - Условие частоты не отрезает все вызовы. Типичная ошибка:
При первом запуске
lastTm == 0иmillis()ещё маленький — ветка не выполняется. Используйте>=и инициализациюlastTmв первом проходе. s_runtime.isOnline() == true. До Online MQTT отключён — публикация не пройдёт.- Размер
JsonDocumentдостаточен для payload. Проверьтеdoc.overflowed()послеserializeJson.
publishTelemetry возвращает false¶
Причины:
- Не подключён к брокеру (
MqttClient::isConnected() == false). - Превышен буфер — payload больше
MQTT_BUFFER_SIZE(16384 байт). Для больших данных используйтеpublishConfigRaw(с чанками) или сократите payload.
DevicePublisher::publishTelemetry не доходит до WS-клиента¶
DevicePublisher не возвращает ошибку, если WS-клиент не подключён — он просто пропускает WS-часть. Проверьте s_local.isClientConnected(). Если false — клиент не аутентифицирован или не подключён.
NTP и время системы¶
Время устройства не синхронизировано¶
NTP-синхронизация запускается в setStateChangeCallback после первого выхода из WifiConnecting:
s_cloud.setStateChangeCallback([](idryer::cloud::CloudState prev,
idryer::cloud::CloudState, void*) {
if (prev == idryer::cloud::CloudState::WifiConnecting) {
configTime(0, 0, "pool.ntp.org", "time.google.com");
}
}, nullptr);
Если этот колбэк не зарегистрирован — время не синхронизируется автоматически. TLS-handshake к брокеру требует валидного времени, иначе сертификат считается просроченным/из будущего.
Альтернативный канал: IdryerRuntime обрабатывает commands/ping и применяет data["timestamp"] через settimeofday(). Если бэкенд шлёт ping раз в минуту — время обновляется без NTP.
TLS-handshake падает после долгого аптайма¶
Если NTP-сервер недоступен и устройство долго работает без перезагрузки, время может сбиться (особенно на ESP32-C3 без TCXO). Симптом: внезапный connection failed после нескольких суток uptime.
Решение: убедиться, что pool.ntp.org доступен из вашей сети, либо чаще получать commands/ping от бэкенда.
getIsoTimestamp возвращает 1970 год¶
Время системы ещё не синхронизировано. Время появляется после первого успешного configTime или commands/ping. До этого момента info/telemetry будут публиковаться с заглушкой.
ArduinoJson¶
Compile error: StaticJsonDocument is not a member of ArduinoJson¶
Вы используете ArduinoJson v7. Тип StaticJsonDocument есть только в v6. Решения:
- Зафиксируйте v6 в
platformio.ini: - Либо мигрируйте свой код на v7 API (
JsonDocumentвместоStaticJsonDocument<N>).idryer-coreнаписан под v6.
Compile error: ambiguous overload или несовпадение типов¶
В одном проекте могут оказаться две версии ArduinoJson через транзитивные зависимости. Проверьте:
Должна быть одна версия. Если две — закрепите явно через lib_deps и при необходимости через lib_ldf_mode = chain+ или lib_ignore.
doc.overflowed() true после serializeJson¶
Размер StaticJsonDocument<N> слишком мал для payload. Увеличьте N либо используйте DynamicJsonDocument для редко вызываемых путей.
Local WS (LocalAccess)¶
App не обнаруживает устройство в LAN¶
mDNS должен быть запущен до WiFi-подключения нет смысла — но сразу после получения серийного номера через s_local.initMdns(serial). Проверьте:
- Маршрутизатор не блокирует multicast.
- App ищет
_idryer._tcpна порту 81. - Серийный номер устройства совпадает с тем, что зарегистрирован в портале.
WS-клиент подключился, но получает auth_required¶
Первое сообщение от клиента должно быть {"type":"auth","token":"<device_token>"}. Если токен невалиден, LocalAccess вызывает setTokenRefreshCallback(). Продукт обязан в этом колбэке перечитать токен из ICredentialStore и вызвать s_local.updateToken(...).
Память и стабильность¶
Free heap падает со временем¶
PubSubClient::loop() и WebSocketsServer::loop() не должны течь, но проверьте свой product code:
JsonDocumentсоздавайте на стеке (StaticJsonDocument<N>), не на куче (DynamicJsonDocument) для часто вызываемых путей.Stringв продуктовом коде на ESP32-C3 быстро фрагментирует кучу — используйтеchar[]иsnprintf.
Stack overflow или Guru Meditation¶
s_runtime.loop() не запускает FreeRTOS-задач — всё крутится в Arduino loop. Если падение в стек, ищите:
- Большие локальные
JsonDocument/char[8192]на стеке Arduino loop (default 8 КБ). - Глубокую рекурсию в продуктовом коде.
Увеличить стек Arduino loop:
Improv WiFi (provisioning через Serial)¶
Improv не принимает credentials¶
Improv должен владеть Serial до получения credentials:
idryer::hal::initArduinoHal(nullptr); // логи в /dev/null пока Improv держит Serial
// ...
if (WiFi.status() == WL_CONNECTED) {
idryer::hal::initArduinoHal(&Serial); // вернуть лог обратно
}
Если HAL_LOG_* пишут в Serial параллельно с Improv-протоколом, Improv ломается на checksum.
Improv-клиент не видит устройство¶
Проверьте ChipFamily в setDeviceInfo. Должен совпадать с реальным чипом: CF_ESP32_C3, CF_ESP32_S3, CF_ESP32_S2, CF_ESP32. Несовпадение — клиент Improv не покажет устройство в списке.
Также убедитесь, что baudrate Serial — 115200. Improv-протокол этого ожидает.
Диагностика интеграций¶
Полный диагностический выхлоп (1 Hz)¶
Меню → DIAGNOSTICS → DIAG LOG (menu.diag_en). По умолчанию выключено.
Включается через UI устройства, портал (commands/set с bind=diag_en),
либо REPL (set diag_en 1).
При включении раз в секунду в Serial выводится блок:
=========== iHeater Link diagnostics ===========
[device] serial=DEVICE_... online=1 uptime=42s
[wifi] status=3 ssid=Apart_4 ip=192.168.0.140 rssi=-51
[rmt-out] mode=DRYING target=70.0°C
[active] bambu
[bambu] state=CONNECTED ip=192.168.0.171 serial=<set> lan=<set>
gcode_state='RUNNING' tray='PLA' chamber_target=0.0 chamber_temp=0.0
[moonraker] state=DISABLED ws=ws://192.168.0.171:7125
vc.available=0 vc.target=0.0 vc.temp=0.0 vc.has_sensor=0
[ha] state=DISABLED host=<empty>:1883 user=<empty>
[menu] bambu_en=1 moon_en=0 ha_en=0 diag_en=1 mat_pla=45 ...
================================================
Полезно для удалённой диагностики: пользователь включает DIAG LOG, копирует
выхлоп → видно state коннекторов, lastError, что реально идёт на RMT.
ANOMALY-канал (event-based)¶
Независимо от diag_en коннекторы и хелперы пишут отдельные строки с
префиксом [!] ANOMALY при неожиданных ситуациях:
[!] ANOMALY HEATER: unknown tray_type='GFA00' — heater OFF (add mapping or check slicer)
[!] ANOMALY BAMBU: report JSON parse error: ... — raw[124]: ...
[!] ANOMALY BAMBU: report has no 'print' object — raw[42]: {"system":...}
Префикс [!] визуально выделяет аномалию в общем потоке логов. Это первое,
что нужно искать в Serial при «не работает».
Auto-OFF при потере связи (fail-safe)¶
Если активная интеграция теряет соединение (TCP/WS disconnect), коннектор немедленно сбрасывает целевую температуру:
- Moonraker —
WStype_DISCONNECTED→chamberTarget=0,available=false→auto_heat::onVirtualChamberUpdate(target=0)→ RMT OFF. - Bambu — переход
Connected → !Connected→chamberTarget=0,trayType=""→auto_heat::onBambuPrinterStatusUpdate(...)→ RMT OFF. - HA — fail-safe пока не реализован.
Без этой логики нагрев продолжался бы на последнем известном target до восстановления коннекта.
Bambu: gcode_state-фильтр¶
auto_heat греет только при gcode_state == "RUNNING" или "PREPARE".
Все остальные (IDLE, FINISH, FAILED, PAUSE, INIT, OFFLINE,
SLICING, UNKNOWN, пусто) → OFF.
При диагностике обращайте внимание на gcode_state в [bambu]-строке
diagnostics — если там IDLE/FINISH, нагрева не будет независимо от
наличия активного трея.
Стенды для отладки без принтера¶
Для проверки интеграций без реальных принтеров продуктовые
репозитории (например, iHeater-link) могут содержать утилиты-заглушки
вроде fake_moonraker / fake_bambu, которые шлют лестницу значений
каждые 30 секунд.