JXCT Soil Sensor 7-in-1 v3.4.9 (June 2025)
Professional IoT soil monitoring system with ESP32, Modbus RTU, MQTT, and advanced compensation algorithms
Загрузка...
Поиск...
Не найдено
routes_data.cpp
См. документацию.
1
6
12#include "../wifi_manager.h"
13#include "../modbus_sensor.h"
14#include "calibration_manager.h"
15#include <ArduinoJson.h>
16#include <NTPClient.h>
17#include <LittleFS.h>
18#include <time.h>
20
21extern NTPClient* timeClient;
22
23// Объявления внешних функций
24extern String navHtml();
25extern String formatValue(float value, const char* unit, int precision);
26extern String getApSsid();
27
28// Буфер для загрузки файлов (калибровка через /readings)
29static File uploadFile;
31
32struct RecValues { float t, hum, ec, ph, n, p, k; };
33
35{
36 // 1. База по культуре или generic
37 RecValues rec{21,60,1200,6.3,30,10,25};
38 const char* id=config.cropId;
39 if(strlen(id)>0){
40 if (strcmp(id,"tomato")==0) rec={22,60,1500,6.5,40,10,30};
41 else if (strcmp(id,"cucumber")==0) rec={24,70,1800,6.2,35,12,28};
42 else if (strcmp(id,"pepper")==0) rec={23,65,1600,6.3,38,11,29};
43 else if (strcmp(id,"lettuce")==0) rec={20,75,1000,6.0,30,8,25};
44 else if (strcmp(id,"blueberry")==0) rec={18,65,1200,5.0,30,10,20};
45 else if (strcmp(id,"lawn")==0) rec={20,50,800,6.3,25,8,20};
46 else if (strcmp(id,"grape")==0) rec={22,55,1400,6.5,35,12,25};
47 else if (strcmp(id,"conifer")==0) rec={18,55,1000,5.5,25,8,15};
48 else if (strcmp(id,"strawberry")==0) rec={20,70,1500,6.0,35,10,25};
49 else if (strcmp(id,"apple")==0) rec={18,60,1200,6.5,25,10,20};
50 else if (strcmp(id,"pear")==0) rec={18,60,1200,6.5,25,10,20};
51 else if (strcmp(id,"cherry")==0) rec={20,60,1300,6.5,30,10,25};
52 else if (strcmp(id,"raspberry")==0) rec={18,65,1100,6.2,30,10,22};
53 else if (strcmp(id,"currant")==0) rec={17,65,1000,6.2,25,9,20};
54 }
55
56 // 2. Коррекция по soilProfile (влажность и pH)
57 int soil=config.soilProfile; // 0 sand,1 loam,2 peat,3 clay
58 if(soil==0){ rec.hum+=-5; /* песок */ }
59 else if(soil==2){ rec.hum+=10; rec.ph-=0.3f; }
60 else if(soil==3){ rec.hum+=5; /* глина */ }
61 else if(soil==1){ rec.hum+=5; }
62
63 // 3. Коррекция по environmentType
64 switch(config.environmentType){
65 case 1: // greenhouse
66 rec.hum+=10; rec.ec+=300; rec.n+=5; rec.k+=5; rec.t+=2; break;
67 case 2: // indoor
68 rec.hum+=-5; rec.ec-=200; rec.t+=1; break;
69 }
70
71 // 3a. Конверсия NPK в мг/кг (датчик выдаёт мг/кг, таблица хранилась в мг/дм³ ~ экстракт 1:5)
72 constexpr float NPK_FACTOR = 6.5f; // пересчёт мг/дм³ → мг/кг (ρ=1.3 г/см³, влажность ≈30%)
73 rec.n *= NPK_FACTOR;
74 rec.p *= NPK_FACTOR;
75 rec.k *= NPK_FACTOR;
76
77 // 4. Сезонная коррекция (только если включена)
78 if(config.flags.seasonalAdjustEnabled){
79 time_t now=time(nullptr); struct tm* ti=localtime(&now);
80 int m=ti?ti->tm_mon+1:1;
81 bool rainy=(m==4||m==5||m==6||m==10);
82
83 // Коррекция влажности и EC
84 if(rainy){ rec.hum+=5; rec.ec-=100; }
85 else{ rec.hum+=-2; rec.ec+=100; }
86
87 // Коррекция NPK по сезону
88 if(config.environmentType == 0) { // Outdoor
89 if(m >= 3 && m <= 5) { // Весна
90 rec.n *= 1.20f; // +20%
91 rec.p *= 1.15f; // +15%
92 rec.k *= 1.10f; // +10%
93 }
94 else if(m >= 6 && m <= 8) { // Лето
95 rec.n *= 0.90f; // -10%
96 rec.p *= 1.05f; // +5%
97 rec.k *= 1.25f; // +25%
98 }
99 else if(m >= 9 && m <= 11) { // Осень
100 rec.n *= 0.80f; // -20%
101 rec.p *= 1.10f; // +10%
102 rec.k *= 1.15f; // +15%
103 }
104 else { // Зима
105 rec.n *= 0.70f; // -30%
106 rec.p *= 1.05f; // +5%
107 rec.k *= 1.05f; // +5%
108 }
109 }
110 else if(config.environmentType == 1) { // Greenhouse
111 if(m >= 3 && m <= 5) { // Весна
112 rec.n *= 1.25f; // +25%
113 rec.p *= 1.20f; // +20%
114 rec.k *= 1.15f; // +15%
115 }
116 else if(m >= 6 && m <= 8) { // Лето
117 rec.n *= 1.10f; // +10%
118 rec.p *= 1.10f; // +10%
119 rec.k *= 1.30f; // +30%
120 }
121 else if(m >= 9 && m <= 11) { // Осень
122 rec.n *= 1.15f; // +15%
123 rec.p *= 1.15f; // +15%
124 rec.k *= 1.20f; // +20%
125 }
126 else { // Зима
127 rec.n *= 1.05f; // +5%
128 rec.p *= 1.10f; // +10%
129 rec.k *= 1.15f; // +15%
130 }
131 }
132 }
133
134 return rec;
135}
136
138{
139 HTTPUpload& upload = webServer.upload();
140 if (upload.status == UPLOAD_FILE_START)
141 {
143 const char* path = CalibrationManager::profileToFilename(SoilProfile::SAND); // custom.csv
144 uploadFile = LittleFS.open(path, "w");
145 if (!uploadFile)
146 {
147 logError("Не удалось создать файл %s", path);
148 }
149 }
150 else if (upload.status == UPLOAD_FILE_WRITE)
151 {
152 if (uploadFile)
153 {
154 uploadFile.write(upload.buf, upload.currentSize);
155 }
156 }
157 else if (upload.status == UPLOAD_FILE_END)
158 {
159 if (uploadFile)
160 {
161 uploadFile.close();
162 logSuccess("Файл калибровки загружен (%u байт)", upload.totalSize);
163 }
164 webServer.sendHeader("Location", "/readings?toast=Калибровка+загружена", true);
165 webServer.send(302, "text/plain", "Redirect");
166 }
167}
168
169static void handleProfileSave()
170{
171 if (webServer.hasArg("soil_profile"))
172 {
173 String profileStr = webServer.arg("soil_profile");
174 if (profileStr == "sand") config.soilProfile = 0;
175 else if (profileStr == "loam") config.soilProfile = 1;
176 else if (profileStr == "peat") config.soilProfile = 2;
177 else if (profileStr == "clay") config.soilProfile = 3;
178
179 saveConfig();
180 logSuccess("Профиль почвы изменён на %s", profileStr.c_str());
181 }
182 webServer.sendHeader("Location", "/readings?toast=Профиль+сохранен", true);
183 webServer.send(302, "text/plain", "Redirect");
184}
185
186static void sendSensorJson()
187{
188 // unified JSON response for sensor data
189 logWebRequest("GET", webServer.uri(), webServer.client().remoteIP().toString());
191 {
192 webServer.send(403, "application/json", "{\"error\":\"AP mode\"}");
193 return;
194 }
195
196 StaticJsonDocument<512> doc;
197 doc["temperature"] = format_temperature(sensorData.temperature);
198 doc["humidity"] = format_moisture(sensorData.humidity);
199 doc["ec"] = format_ec(sensorData.ec);
200 doc["ph"] = format_ph(sensorData.ph);
201 doc["nitrogen"] = format_npk(sensorData.nitrogen);
202 doc["phosphorus"] = format_npk(sensorData.phosphorus);
203 doc["potassium"] = format_npk(sensorData.potassium);
204 doc["raw_temperature"] = format_temperature(sensorData.raw_temperature);
205 doc["raw_humidity"] = format_moisture(sensorData.raw_humidity);
206 doc["raw_ec"] = format_ec(sensorData.raw_ec);
207 doc["raw_ph"] = format_ph(sensorData.raw_ph);
208 doc["raw_nitrogen"] = format_npk(sensorData.raw_nitrogen);
209 doc["raw_phosphorus"] = format_npk(sensorData.raw_phosphorus);
210 doc["raw_potassium"] = format_npk(sensorData.raw_potassium);
211 doc["irrigation"] = sensorData.recentIrrigation;
212
214 doc["rec_temperature"] = format_temperature(rec.t);
215 doc["rec_humidity"] = format_moisture(rec.hum);
216 doc["rec_ec"] = format_ec(rec.ec);
217 doc["rec_ph"] = format_ph(rec.ph);
218 doc["rec_nitrogen"] = format_npk(rec.n);
219 doc["rec_phosphorus"] = format_npk(rec.p);
220 doc["rec_potassium"] = format_npk(rec.k);
221
222 // ---- Дополнительная информация ----
223 // Сезон по текущему месяцу
224 const char* seasonName = [](){
225 // Проверяем инициализацию NTP
226 if (timeClient == nullptr) {
227 extern WiFiUDP ntpUDP;
228 timeClient = new NTPClient(ntpUDP, "pool.ntp.org", 0, 3600000);
229 timeClient->begin();
230 }
231
232 time_t now = timeClient ? (time_t)timeClient->getEpochTime() : time(nullptr);
233 // если время < 2000-01-01 считаем, что NTP ещё не синхронизирован
234 if (now < 946684800) {
235 // Пробуем обновить NTP
236 if (timeClient) {
237 timeClient->forceUpdate();
238 now = (time_t)timeClient->getEpochTime();
239 if (now < 946684800) return "Н/Д";
240 } else {
241 return "Н/Д";
242 }
243 }
244 struct tm* ti = localtime(&now);
245 if (!ti) return "Н/Д";
246 uint8_t m = ti->tm_mon + 1;
247 if (m==12 || m==1 || m==2) return "Зима";
248 if (m>=3 && m<=5) return "Весна";
249 if (m>=6 && m<=8) return "Лето";
250 return "Осень";
251 }();
252 doc["season"] = seasonName;
253
254 // Проверяем отклонения
255 String alerts="";
256 auto append=[&](const char* n){ if(alerts.length()) alerts += ", "; alerts += n; };
257 // Физические пределы датчика
258 if (sensorData.temperature < -45 || sensorData.temperature > 115) append("T");
259 if (sensorData.humidity < 0 || sensorData.humidity > 100) append("θ");
260 if (sensorData.ec < 0 || sensorData.ec > 10000) append("EC");
261 if (sensorData.ph < 3 || sensorData.ph > 9) append("pH");
262 if (sensorData.nitrogen < 0 || sensorData.nitrogen > 1999) append("N");
263 if (sensorData.phosphorus < 0 || sensorData.phosphorus > 1999) append("P");
264 if (sensorData.potassium < 0 || sensorData.potassium > 1999) append("K");
265 doc["alerts"] = alerts;
266
267 doc["timestamp"] = (long)(timeClient ? timeClient->getEpochTime() : 0);
268
269 String json;
270 serializeJson(doc, json);
271 webServer.send(200, "application/json", json);
272}
273
275{
276 // Красивая страница показаний с иконками (оригинальный дизайн)
277 webServer.on("/readings", HTTP_GET,
278 []()
279 {
280 logWebRequest("GET", "/readings", webServer.client().remoteIP().toString());
281
283 {
284 webServer.send(200, "text/html; charset=utf-8", generateApModeUnavailablePage("Показания", UI_ICON_DATA));
285 return;
286 }
287
288 String html = generatePageHeader("Показания датчика", UI_ICON_DATA);
289 html += navHtml();
290 html += "<h1>" UI_ICON_DATA " Показания датчика</h1>";
291
292 // Информационная строка состояния
293 html += "<div id='statusInfo' style='margin:10px 0;font-size:16px;color:#333'></div>";
294
295 // ======= ОБЪЯСНЕНИЕ ПРОЦЕССОВ =======
296 html += "<div class='section' style='background:#f8f9fa;padding:15px;border-radius:8px;margin:15px 0;'>";
297 html += "<h3>📋 Как работают показания</h3>";
298 html += "<div style='display:grid;grid-template-columns:1fr 1fr;gap:20px;font-size:14px;'>";
299
300 // Левая колонка - компенсация
301 html += "<div>";
302 html += "<h4>🔧 Компенсация показаний</h4>";
303 html += "<ul style='margin:0;padding-left:20px;'>";
304 html += "<li><strong>RAW</strong> - сырые данные с датчика</li>";
305 html += "<li><strong>Компенс.</strong> - данные после математической компенсации:</li>";
306 html += "<ul style='margin:5px 0;padding-left:15px;'>";
307 html += "<li>🌡️ <strong>Температура:</strong> без изменений</li>";
308 html += "<li>💧 <strong>Влажность:</strong> без изменений</li>";
309 html += "<li>⚡ <strong>EC:</strong> температурная компенсация + модель Арчи (Archie, 1942)</li>";
310 html += "<li>⚗️ <strong>pH:</strong> температурная поправка по Нернсту (-0.003×ΔT)</li>";
311 html += "<li>🔴🟡🔵 <strong>NPK:</strong> коррекция по T, влажности и типу почвы (FAO 56 + Eur. J. Soil Sci.)</li>";
312 html += "</ul>";
313 html += "</ul>";
314 html += "</div>";
315
316 // Правая колонка - рекомендации
317 html += "<div>";
318 html += "<h4>🎯 Рекомендации</h4>";
319 html += "<ul style='margin:0;padding-left:20px;'>";
320 html += "<li><strong>Базовые нормы</strong> для выбранной культуры</li>";
321 html += "<li><strong>Сезонные корректировки</strong> (весна/лето/осень/зима)</li>";
322 html += "<li><strong>Тип среды</strong> (открытый грунт/теплица/помещение)</li>";
323 html += "<li><strong>Цветовая индикация:</strong></li>";
324 html += "<ul style='margin:5px 0;padding-left:15px;'>";
325 html += "<li>🟢 <strong>Зеленый:</strong> в норме</li>";
326 html += "<li>🟡 <strong>Желтый:</strong> близко к границам</li>";
327 html += "<li>🟠 <strong>Оранжевый:</strong> отклонение >20%</li>";
328 html += "<li>🔴 <strong>Красный:</strong> критическое отклонение</li>";
329 html += "</ul>";
330 html += "</ul>";
331 html += "</div>";
332
333 html += "</div>";
334 html += "</div>";
335
336 // Заголовок 4-го столбца: выбранная культура или «Реком.»
337 String recHeader = "Реком.";
338 if (strlen(config.cropId) > 0)
339 {
340 const char* id = config.cropId;
341 if (strcmp(id,"tomato")==0) recHeader = "Томаты";
342 else if (strcmp(id,"cucumber")==0) recHeader = "Огурцы";
343 else if (strcmp(id,"pepper")==0) recHeader = "Перец";
344 else if (strcmp(id,"lettuce")==0) recHeader = "Салат";
345 else if (strcmp(id,"blueberry")==0) recHeader = "Голубика";
346 else if (strcmp(id,"lawn")==0) recHeader = "Газон";
347 else if (strcmp(id,"grape")==0) recHeader = "Виноград";
348 else if (strcmp(id,"conifer")==0) recHeader = "Хвойные";
349 else if (strcmp(id,"strawberry")==0) recHeader = "Клубника";
350 else if (strcmp(id,"apple")==0) recHeader = "Яблоня";
351 else if (strcmp(id,"pear")==0) recHeader = "Груша";
352 else if (strcmp(id,"cherry")==0) recHeader = "Вишня";
353 else if (strcmp(id,"raspberry")==0) recHeader = "Малина";
354 else if (strcmp(id,"currant")==0) recHeader = "Смородина";
355 }
356
357 html += "<div class='section'><table class='data'><thead><tr><th></th><th>RAW</th><th>Компенс.</th><th>" + recHeader + "</th></tr></thead><tbody>";
358 html += "<tr><td>🌡️ Температура, °C</td><td><span id='temp_raw'></span></td><td><span id='temp'></span></td><td><span id='temp_rec'></span></td></tr>";
359 html += "<tr><td>💧 Влажность, %</td><td><span id='hum_raw'></span></td><td><span id='hum'></span></td><td><span id='hum_rec'></span></td></tr>";
360 html += "<tr><td>⚡ EC, µS/cm</td><td><span id='ec_raw'></span></td><td><span id='ec'></span></td><td><span id='ec_rec'></span></td></tr>";
361 html += "<tr><td>⚗️ pH</td><td><span id='ph_raw'></span></td><td><span id='ph'></span></td><td><span id='ph_rec'></span></td></tr>";
362 html += "<tr><td>🔴 Азот (N), мг/кг</td><td><span id='n_raw'></span></td><td><span id='n'></span></td><td><span id='n_rec'></span><span id='n_season' class='season-adj'></span></td></tr>";
363 html += "<tr><td>🟡 Фосфор (P), мг/кг</td><td><span id='p_raw'></span></td><td><span id='p'></span></td><td><span id='p_rec'></span><span id='p_season' class='season-adj'></span></td></tr>";
364 html += "<tr><td>🔵 Калий (K), мг/кг</td><td><span id='k_raw'></span></td><td><span id='k'></span></td><td><span id='k_rec'></span><span id='k_season' class='season-adj'></span></td></tr>";
365 html += "</tbody></table></div>";
366
367 // ======= КАЛИБРОВКА =======
368 bool csvPresent = CalibrationManager::hasTable(SoilProfile::SAND); // custom.csv
369
370 html += "<div class='section'><h2>⚙️ Калибровка датчика</h2>";
371
372 // Статус калибровки
373 html += "<div style='background:#f8f9fa;padding:15px;border-radius:8px;margin:15px 0;'>";
374 html += "<h4>📊 Текущий статус калибровки</h4>";
375 if (!config.flags.calibrationEnabled) {
376 html += "<p style='color:#9E9E9E;margin:5px 0;'>❌ <strong>Компенсация выключена</strong> - используются только математические поправки</p>";
377 } else if (csvPresent) {
378 html += "<p style='color:#4CAF50;margin:5px 0;'>✅ <strong>CSV таблица загружена</strong> - применяется лабораторная калибровка + математическая компенсация</p>";
379 } else {
380 html += "<p style='color:#2196F3;margin:5px 0;'>⚠️ <strong>CSV таблица не загружена</strong> - применяется только математическая компенсация</p>";
381 }
382 html += "</div>";
383
384 // Объяснение калибровочной таблицы
385 html += "<div style='background:#f8f9fa;border-left:4px solid #007bff;padding:10px;margin:10px 0;'>";
386 html += "<h4 style='margin:0 0 8px 0;color:#007bff;'>📊 <strong>Калибровочная таблица (CSV)</strong></h4>";
387 html += "<p style='margin:5px 0;font-size:14px;'>";
388 html += "Это файл с коэффициентами коррекции, полученными при поверке датчика в лаборатории. ";
389 html += "Содержит коррекции для всех параметров: <strong>температура, влажность, EC, pH, азот, фосфор, калий</strong>. ";
390 html += "Формат: <code>сырое_значение,коэффициент_коррекции</code>";
391 html += "</p>";
392 html += "<p style='margin:5px 0;font-size:14px;'>";
393 html += "💡 <strong>Применение:</strong> <code>скорректированное = сырое × коэффициент</code>";
394 html += "</p>";
395 html += "<p style='margin:5px 0;font-size:14px;'>";
396 html += "📄 <a href='/docs/examples/calibration_example.csv' target='_blank' style='color:#2196F3;'>Скачать пример CSV файла</a>";
397 html += "</p>";
398 html += "</div>";
399
400 // Форма загрузки CSV
401 html += "<form action='/readings/upload' method='post' enctype='multipart/form-data' style='margin-top:15px;'>";
402 html += "<div class='form-group'><label for='calibration_csv'><strong>Загрузить CSV файл калибровки:</strong></label>";
403 html += "<input type='file' id='calibration_csv' name='calibration_csv' accept='.csv' required style='margin:5px 0;'>";
404 html += "<div style='font-size:12px;color:#666;margin:5px 0;'>Файл должен содержать пары значений: сырое_значение,коэффициент_коррекции</div>";
405 html += "</div>";
406 html += generateButton(ButtonType::PRIMARY, UI_ICON_UPLOAD, "Загрузить CSV", "");
407 html += "</form>";
408
409 // Кнопка сброса CSV, если файл существует
410 if(csvPresent){
411 html += "<form action='/readings/csv_reset' method='post' style='margin-top:10px;'>";
412 html += generateButton(ButtonType::SECONDARY, "🗑️", "Удалить CSV таблицу", "");
413 html += "</form>";
414 }
415 html += "</div>";
416
417 // ======= ДОПОЛНИТЕЛЬНАЯ ИНФОРМАЦИЯ =======
418 html += "<div class='section' style='background:#e8f5e8;padding:15px;border-radius:8px;margin:15px 0;'>";
419 html += "<h4>💡 Полезная информация</h4>";
420 html += "<ul style='margin:5px 0;padding-left:20px;font-size:14px;'>";
421 html += "<li><strong>Стрелки ↑↓</strong> показывают направление изменений после компенсации</li>";
422 html += "<li><strong>Сезонные корректировки</strong> учитывают потребности растений в разные периоды</li>";
423 html += "<li><strong>Валидность данных</strong> проверяется по диапазонам и логическим связям</li>";
424 html += "<li><strong>Интервал обновления:</strong> каждые 3 секунды</li>";
425 html += "</ul>";
426 html += "</div>";
427
428 html += "<style>";
429 html += ".season-adj { font-size: 0.8em; margin-left: 5px; }";
430 html += ".season-adj.up { color: #2ecc71; }";
431 html += ".season-adj.down { color: #e74c3c; }";
432 html += ".data{width:100%;border-collapse:collapse}.data th,.data td{border:1px solid #ccc;padding:6px;text-align:center}.data th{background:#f5f5f5}.green{color:#4CAF50}.yellow{color:#FFC107}.orange{color:#FF9800}.red{color:#F44336}";
433 html += "</style>";
434
435 html += "<script>";
436 html += "function set(id,v){if(v!==undefined&&v!==null){document.getElementById(id).textContent=v;}}";
437 html += "function colorDelta(a,b){var diff=Math.abs(a-b)/b*100;if(diff>30)return 'red';if(diff>20)return 'orange';if(diff>10)return 'yellow';return '';}";
438 html += "function colorRange(v,min,max){var span=(max-min);if(span<=0)return '';if(v<min||v>max)return 'red';if(v<min+0.05*span||v>max-0.05*span)return 'orange';if(v<min+0.10*span||v>max-0.10*span)return 'yellow';return '';}";
439 html += "function applyColor(spanId,cls){var el=document.getElementById(spanId);if(!el)return;el.classList.remove('red','orange','yellow','green');if(cls){el.classList.add(cls);}else{el.classList.add('green');}}";
440 html += "var limits={temp:{min:-45,max:115},hum:{min:0,max:100},ec:{min:0,max:10000},ph:{min:3,max:9},n:{min:0,max:1999},p:{min:0,max:1999},k:{min:0,max:1999}};";
441 html += "function updateSensor(){";
442 html += "fetch('/sensor_json').then(r=>r.json()).then(d=>{";
443 html += "set('temp_raw',d.raw_temperature);";
444 html += "set('hum_raw',d.raw_humidity);";
445 html += "set('ec_raw',d.raw_ec);";
446 html += "set('ph_raw',d.raw_ph);";
447 html += "set('n_raw',d.raw_nitrogen);";
448 html += "set('p_raw',d.raw_phosphorus);";
449 html += "set('k_raw',d.raw_potassium);";
450 html += "set('temp_rec',d.rec_temperature);set('hum_rec',d.rec_humidity);set('ec_rec',d.rec_ec);set('ph_rec',d.rec_ph);set('n_rec',d.rec_nitrogen);set('p_rec',d.rec_phosphorus);set('k_rec',d.rec_potassium);";
451 // === Arrow indicators block ===
452 html += "const tol={temp:0.2,hum:0.5,ec:20,ph:0.05,n:5,p:3,k:3};";
453 html += "function arrowSign(base,val,thr){base=parseFloat(base);val=parseFloat(val);if(isNaN(base)||isNaN(val))return '';if(val>base+thr)return '↑ ';if(val<base-thr)return '↓ ';return '';};";
454 html += "function showWithArrow(id,sign,value){document.getElementById(id).textContent=sign+value;}";
455
456 // Compensated vs RAW arrows
457 html += "showWithArrow('temp', arrowSign(d.raw_temperature ,d.temperature ,tol.temp), d.temperature);";
458 html += "showWithArrow('hum', arrowSign(d.raw_humidity ,d.humidity ,tol.hum ), d.humidity);";
459 html += "showWithArrow('ec', arrowSign(d.raw_ec ,d.ec ,tol.ec ), d.ec);";
460 html += "showWithArrow('ph', arrowSign(d.raw_ph ,d.ph ,tol.ph ), d.ph);";
461 html += "showWithArrow('n', arrowSign(d.raw_nitrogen ,d.nitrogen ,tol.n ), d.nitrogen);";
462 html += "showWithArrow('p', arrowSign(d.raw_phosphorus ,d.phosphorus ,tol.p ), d.phosphorus);";
463 html += "showWithArrow('k', arrowSign(d.raw_potassium ,d.potassium ,tol.k ), d.potassium);";
464
465 // Recommendation arrows (target vs current)
466 html += "showWithArrow('temp_rec', arrowSign(d.temperature ,d.rec_temperature ,tol.temp), d.rec_temperature);";
467 html += "showWithArrow('hum_rec', arrowSign(d.humidity ,d.rec_humidity ,tol.hum ), d.rec_humidity);";
468 html += "showWithArrow('ec_rec', arrowSign(d.ec ,d.rec_ec ,tol.ec ), d.rec_ec);";
469 html += "showWithArrow('ph_rec', arrowSign(d.ph ,d.rec_ph ,tol.ph ), d.rec_ph);";
470 html += "showWithArrow('n_rec', arrowSign(d.nitrogen ,d.rec_nitrogen ,tol.n ), d.rec_nitrogen);";
471 html += "showWithArrow('p_rec', arrowSign(d.phosphorus ,d.rec_phosphorus ,tol.p ), d.rec_phosphorus);";
472 html += "showWithArrow('k_rec', arrowSign(d.potassium ,d.rec_potassium ,tol.k ), d.rec_potassium);";
473 // === End arrow indicators ===
474
475 // Добавляем индикацию сезонных корректировок
476 html += "function updateSeasonalAdjustments(season) {";
477 html += " const adjustments = {";
478 html += " 'Весна': { n: '+20%', p: '+15%', k: '+10%' },";
479 html += " 'Лето': { n: '-10%', p: '+5%', k: '+25%' },";
480 html += " 'Осень': { n: '-20%', p: '+10%', k: '+15%' },";
481 html += " 'Зима': { n: '-30%', p: '+5%', k: '+5%' }";
482 html += " };";
483 html += " const envType = " + String(config.environmentType) + ";";
484 html += " if(envType === 1) {"; // Теплица
485 html += " adjustments['Весна'] = { n: '+25%', p: '+20%', k: '+15%' };";
486 html += " adjustments['Лето'] = { n: '+10%', p: '+10%', k: '+30%' };";
487 html += " adjustments['Осень'] = { n: '+15%', p: '+15%', k: '+20%' };";
488 html += " adjustments['Зима'] = { n: '+5%', p: '+10%', k: '+15%' };";
489 html += " }";
490 html += " const adj = adjustments[season] || { n: '', p: '', k: '' };";
491 html += " ['n', 'p', 'k'].forEach(elem => {";
492 html += " const span = document.getElementById(elem + '_season');";
493 html += " if(span) {";
494 html += " span.textContent = adj[elem] ? ` (${adj[elem]})` : '';";
495 html += " span.className = 'season-adj ' + (adj[elem].startsWith('+') ? 'up' : 'down');";
496 html += " }";
497 html += " });";
498 html += "}";
499
500 html += "var invalid = d.irrigation || d.alerts.length>0 || d.humidity<25 || d.temperature<5 || d.temperature>40;";
501 html += "var statusHtml = invalid ? '<span class=\\\"red\\\">Данные&nbsp;не&nbsp;валидны</span>' : '<span class=\\\"green\\\">Данные&nbsp;валидны</span>';";
502 html += "var seasonColor={'Лето':'green','Весна':'yellow','Осень':'yellow','Зима':'red','Н/Д':''}[d.season]||'';";
503 html += "var seasonHtml=seasonColor?(`<span class=\\\"${seasonColor}\\\">${d.season}</span>`):d.season;";
504 html += "document.getElementById('statusInfo').innerHTML=statusHtml+' | Сезон: '+seasonHtml;";
505 html += "updateSeasonalAdjustments(d.season);";
506 html += "var tvr=parseFloat(d.raw_temperature);applyColor('temp_raw',colorRange(tvr,limits.temp.min,limits.temp.max));";
507 html += "var hvr=parseFloat(d.raw_humidity);applyColor('hum_raw',colorRange(hvr,limits.hum.min,limits.hum.max));";
508 html += "var evr=parseFloat(d.raw_ec);applyColor('ec_raw',colorRange(evr,limits.ec.min,limits.ec.max));";
509 html += "var pvr=parseFloat(d.raw_ph);applyColor('ph_raw',colorRange(pvr,limits.ph.min,limits.ph.max));";
510 html += "var nvr=parseFloat(d.raw_nitrogen);applyColor('n_raw',colorRange(nvr,limits.n.min,limits.n.max));";
511 html += "var p2r=parseFloat(d.raw_phosphorus);applyColor('p_raw',colorRange(p2r,limits.p.min,limits.p.max));";
512 html += "var kvr=parseFloat(d.raw_potassium);applyColor('k_raw',colorRange(kvr,limits.k.min,limits.k.max));";
513 html += "['temp','hum','ec','ph','n','p','k'].forEach(function(id){var el=document.getElementById(id);if(el){el.classList.remove('red','orange','yellow','green');}});";
514 html += "var ct=parseFloat(d.temperature);";
515 html += "var ch=parseFloat(d.humidity);";
516 html += "var ce=parseFloat(d.ec);";
517 html += "var cph=parseFloat(d.ph);";
518 html += "var cn=parseFloat(d.nitrogen);";
519 html += "var cp=parseFloat(d.phosphorus);";
520 html += "var ck=parseFloat(d.potassium);";
521 html += "applyColor('temp_rec', colorDelta(ct, parseFloat(d.rec_temperature)));";
522 html += "applyColor('hum_rec', colorDelta(ch, parseFloat(d.rec_humidity)));";
523 html += "applyColor('ec_rec', colorDelta(ce, parseFloat(d.rec_ec)));";
524 html += "applyColor('ph_rec', colorDelta(cph,parseFloat(d.rec_ph)));";
525 html += "applyColor('n_rec', colorDelta(cn, parseFloat(d.rec_nitrogen)));";
526 html += "applyColor('p_rec', colorDelta(cp, parseFloat(d.rec_phosphorus)));";
527 html += "applyColor('k_rec', colorDelta(ck, parseFloat(d.rec_potassium)));";
528 html += "});";
529 html += "}";
530 html += "setInterval(updateSensor,3000);";
531 html += "updateSensor();";
532 html += "</script>";
533
534 // API-ссылка внизу страницы
535 html += "<div style='margin-top:15px;font-size:14px;color:#555'><b>API:</b> <a href='" + String(API_SENSOR) + "' target='_blank'>" + String(API_SENSOR) + "</a> (JSON, +timestamp)</div>";
536
537 html += generatePageFooter();
538 webServer.send(200, "text/html; charset=utf-8", html);
539 });
540
541 // AJAX эндпоинт для обновления показаний
542 webServer.on("/sensor_json", HTTP_GET, sendSensorJson);
543
544 // Primary API v1 endpoint
545 webServer.on(API_SENSOR, HTTP_GET, sendSensorJson);
546
547 // Загрузка калибровочного CSV через вкладку
548 webServer.on("/readings/upload", HTTP_POST, [](){}, handleReadingsUpload);
549
550 // Сброс пользовательских CSV (удаляем все *.csv)
551 webServer.on("/readings/csv_reset", HTTP_POST,
552 []() {
553 logWebRequest("POST","/readings/csv_reset", webServer.client().remoteIP().toString());
556 String toast = removed?"CSV+удален":"CSV+не+найден";
557 webServer.sendHeader("Location", String("/readings?toast=") + toast, true);
558 webServer.send(302,"text/plain","Redirect");
559 });
560
561 // Форма для сохранения профиля
562 webServer.on("/readings/profile", HTTP_POST, [](){}, handleProfileSave);
563
564 // Обслуживание статических файлов из LittleFS
565 webServer.on("/docs/examples/calibration_example.csv", HTTP_GET,
566 []() {
567 logWebRequest("GET", "/docs/examples/calibration_example.csv", webServer.client().remoteIP().toString());
568
569 if (LittleFS.exists("/docs/examples/calibration_example.csv")) {
570 File file = LittleFS.open("/docs/examples/calibration_example.csv", "r");
571 if (file) {
572 webServer.sendHeader("Content-Type", "text/csv");
573 webServer.sendHeader("Content-Disposition", "attachment; filename=\"calibration_example.csv\"");
574 webServer.streamFile(file, "text/csv");
575 file.close();
576 } else {
577 webServer.send(404, "text/plain", "File not found");
578 }
579 } else {
580 // Если файл не найден, создаем его на лету
581 webServer.sendHeader("Content-Type", "text/csv");
582 webServer.sendHeader("Content-Disposition", "attachment; filename=\"calibration_example.csv\"");
583 String csvContent = "# Пример калибровочной таблицы для JXCT датчика\n";
584 csvContent += "# Формат: сырое_значение,коэффициент_коррекции\n";
585 csvContent += "# Коэффициент применяется как: скорректированное_значение = сырое_значение * коэффициент\n\n";
586 csvContent += "# Электропроводность (µS/cm) - может требовать коррекции\n";
587 csvContent += "0,1.000\n";
588 csvContent += "500,0.98\n";
589 csvContent += "1000,0.95\n";
590 csvContent += "1500,0.93\n";
591 csvContent += "2000,0.91\n";
592 csvContent += "3000,0.89\n";
593 csvContent += "5000,0.87\n\n";
594 csvContent += "# pH - может требовать коррекции\n";
595 csvContent += "3.0,1.000\n";
596 csvContent += "4.0,1.000\n";
597 csvContent += "5.0,1.000\n";
598 csvContent += "6.0,1.000\n";
599 csvContent += "7.0,1.000\n";
600 csvContent += "8.0,1.000\n";
601 csvContent += "9.0,1.000\n\n";
602 csvContent += "# Азот (мг/кг) - может требовать коррекции\n";
603 csvContent += "0,1.000\n";
604 csvContent += "100,0.95\n";
605 csvContent += "200,0.92\n";
606 csvContent += "500,0.89\n";
607 csvContent += "1000,0.87\n";
608 csvContent += "1500,0.85\n";
609 webServer.send(200, "text/csv", csvContent);
610 }
611 });
612
613 // Deprecated alias удалён в v2.7.0
614
615 logDebug("Маршруты данных настроены: /readings, /api/v1/sensor (json), /sensor_json [legacy]");
616}
617
618// Вспомогательная функция для получения SSID точки доступа
619extern String getApSsid();
Config config
Определения config.cpp:34
void saveConfig()
Определения config.cpp:128
void logWebRequest(const String &method, const String &uri, const String &clientIP)
Логирование веб-запросов
Определения error_handlers.cpp:152
std::string format_ec(float value)
Определения jxct_format_utils.cpp:18
std::string format_moisture(float value)
Определения jxct_format_utils.cpp:4
std::string format_ph(float value)
Определения jxct_format_utils.cpp:25
std::string format_temperature(float value)
Определения jxct_format_utils.cpp:11
std::string format_npk(float value)
Определения jxct_format_utils.cpp:32
#define API_SENSOR
Определения jxct_strings.h:8
String generateButton(ButtonType type, const char *icon, const char *text, const char *action)
Определения jxct_ui_system.cpp:286
#define UI_ICON_DATA
Определения jxct_ui_system.h:48
#define UI_ICON_UPLOAD
Определения jxct_ui_system.h:45
@ SECONDARY
Определения jxct_ui_system.h:66
@ PRIMARY
Определения jxct_ui_system.h:65
void logDebug(const char *format,...)
Определения logger.cpp:112
void logSuccess(const char *format,...)
Определения logger.cpp:129
void logError(const char *format,...)
Определения logger.cpp:61
Система логгирования с красивым форматированием
WiFiUDP ntpUDP
Определения main.cpp:42
NTPClient * timeClient
Определения main.cpp:43
SensorData sensorData
Определения modbus_sensor.cpp:18
bool deleteTable(SoilProfile profile)
Определения calibration_manager.cpp:100
bool hasTable(SoilProfile profile)
Определения calibration_manager.cpp:94
const char * profileToFilename(SoilProfile)
Определения calibration_manager.cpp:9
bool init()
Определения calibration_manager.cpp:14
static SoilProfile uploadProfile
Определения routes_calibration.cpp:80
WebServer webServer
static File uploadFile
Определения routes_calibration.cpp:79
WiFiMode currentWiFiMode
Определения wifi_manager.cpp:26
static void handleReadingsUpload()
Определения routes_data.cpp:137
static void sendSensorJson()
Определения routes_data.cpp:186
static void handleProfileSave()
Определения routes_data.cpp:169
String navHtml()
Определения wifi_manager.cpp:82
String formatValue(float value, const char *unit, int precision)
Определения jxct_format_utils.cpp:40
static RecValues computeRecommendations()
Определения routes_data.cpp:34
void setupDataRoutes()
Настройка маршрутов данных датчика (/readings, /sensor_json, /api/sensor)
Определения routes_data.cpp:274
String getApSsid()
Определения wifi_manager.cpp:206
SoilProfile
Определения sensor_compensation.h:14
@ SAND
Определения sensor_compensation.h:15
Определения routes_data.cpp:32
float p
Определения routes_data.cpp:32
float n
Определения routes_data.cpp:32
float k
Определения routes_data.cpp:32
float ec
Определения routes_data.cpp:32
float ph
Определения routes_data.cpp:32
float t
Определения routes_data.cpp:32
float hum
Определения routes_data.cpp:32
String generateApModeUnavailablePage(const String &title, const String &icon)
Генерация страницы "Недоступно в AP режиме".
Определения web_templates.cpp:175
String generatePageHeader(const String &title, const String &icon)
Генерация заголовка HTML страницы
Определения web_templates.cpp:8
String generatePageFooter()
Генерация футера HTML страницы
Определения web_templates.cpp:19
@ AP
Определения wifi_manager.h:13
@ STA
Определения wifi_manager.h:14