Вечером хочется посмотреть, что происходило в хозяйстве: как нагрелась теплица, когда отозвался почвенный датчик, какой маршрут прошла техника. Соберём место для такой истории. InfluxDB будет хранить измерения со временем, а Grafana покажет их графиками, таблицами и картой.
Это учебный сервер наблюдений. Начнём с одного датчика, проверим время и единицы, затем добавим остальные панели. Прежние сообщения в Telegram можно сохранить параллельно. Учёт операций и агрономические решения будем сверять с их исходными данными.
Готовим компьютер для истории измерений
Для стенда подойдёт Raspberry Pi 4 с Raspberry Pi OS Lite 64-bit или другой совместимый компьютер. Подготовьте питание, накопитель, сеть и место для резервных копий. Размер истории и нагрузку оцените по частоте записи, сроку хранения и запросам. Это поможет выбрать накопитель и проверить его работу.
Через Raspberry Pi Imager задайте собственные имя пользователя, пароль или SSH-ключ и имя сервера; пользователя pi по умолчанию не предполагаем. Закрепите адрес устройства в роутере. Пример 192.168.1.100 замените во всех настройках и сертификате.
Проверены InfluxDB OSS 2.9.1, пакет influx CLI 2.8.0 и Grafana OSS 13.2.2. Запросы Flux предназначены для InfluxDB 2, а не 3: различия указаны в документации Grafana. Не заменяйте выбранное поколение базы образом latest.
На новом сервере установите стабильные ARM64-пакеты по инструкциям InfluxDB 2 и Grafana для Debian. influx CLI устанавливается отдельно. Проверьте версии; существующий сервер сначала требует резервной копии и проверки совместимости.
influxd version
influx version
grafana server -v
sudo systemctl stop influxdb grafana-server
До настройки доступа оставьте сервер в доверенной локальной сети без проброса портов. Дальше предполагается пакетная установка со службами influxdb и grafana-server; архивы сами их не создают.
Датчики записывают, Grafana читает
Сертификат для локального сервера
Для HTTPS создайте собственный центр сертификации на доверенном компьютере с OpenSSL. Выполните команды в Bash: они создают новую папку farm-tls. Замените IP в server.ext и проверке на свой; сохраните пароль ca.key.
set -eu
umask 077
mkdir farm-tls
cd farm-tls
openssl genpkey -algorithm RSA -aes-256-cbc -pkeyopt rsa_keygen_bits:3072 -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 3650 -out ca.crt \
-subj '/CN=Farm Local CA' \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,keyCertSign,cRLSign'
openssl req -new -newkey rsa:2048 -nodes -keyout server.key -out server.csr \
-subj '/CN=farm-server.local'
cat > server.ext <<'EXT'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:farm-server.local,IP:192.168.1.100
EXT
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 365 -sha256 -extfile server.ext
openssl verify -CAfile ca.crt -verify_ip 192.168.1.100 server.crt
ca.crt — открытый сертификат доверия для клиентов. Закрытый ca.key храните вне сервера и прошивок; server.key нужен только серверным службам. Сертификат сервера действует 365 дней: запланируйте обновление. Не отключайте TLS-проверку из-за ошибки имени или срока.
Перенесите на сервер только ca.crt, server.crt и server.key защищённым способом. Из папки с этими файлами выполните:
sudo install -d -o root -g influxdb -m 750 /etc/influxdb/tls
sudo install -o root -g influxdb -m 640 server.crt server.key /etc/influxdb/tls/
sudo install -d -o root -g grafana -m 750 /etc/grafana/tls
sudo install -o root -g grafana -m 640 server.crt server.key /etc/grafana/tls/
sudo systemctl edit influxdb
В дополнение службы добавьте TLS-параметры; каталог базы остаётся прежним:
[Service]
Environment="INFLUXD_TLS_CERT=/etc/influxdb/tls/server.crt"
Environment="INFLUXD_TLS_KEY=/etc/influxdb/tls/server.key"
Environment="INFLUXD_HTTP_BIND_ADDRESS=192.168.1.100:8086"
В /etc/grafana/grafana.ini измените параметры существующих секций, не дублируя их:
[server]
protocol = https
http_addr = 192.168.1.100
http_port = 3000
cert_file = /etc/grafana/tls/server.crt
cert_key = /etc/grafana/tls/server.key
[auth.anonymous]
enabled = false
[users]
allow_sign_up = false
[security]
cookie_secure = true
sudo systemctl daemon-reload
sudo systemctl enable --now influxdb grafana-server
sudo systemctl status influxdb grafana-server
curl --fail --cacert ca.crt https://192.168.1.100:8086/health
curl --fail --cacert ca.crt https://192.168.1.100:3000/api/health
Добавьте ca.crt в доверенные сертификаты устройств и откройте HTTPS-адреса. Если возникла ошибка, проверьте журнал службы, права на ключ и IP сертификата. Затем смените пароль администратора Grafana и создайте Viewer для просмотра. После соединения перейдём к первому измерению.
Организация, bucket и токены
В InfluxDB создайте организацию farm и bucket sensors с хранением 30 дней — это срок учебного примера. Сохраните первоначальный операторский токен для администрирования и копий, не передавайте его датчикам.
В API Tokens создайте Custom Token с правом только Write для sensors, а для Grafana — только Read. Отдельные токены устройств позволяют раздельный отзыв, но право на bucket не ограничивает тег node. Для изоляции недоверенных устройств нужны разные buckets или проверяющий шлюз.
В Grafana: Connections → Data sources → Add data source → InfluxDB. Выберите Flux, URL https://192.168.1.100:8086, Organization farm, Default Bucket sensors и токен чтения. Добавьте ca.crt как собственный CA; Skip TLS verify выключен. После Save & Test проверьте запрос с записанной точкой.
Отправляем первую точку
Line Protocol содержит имя измерения, теги, поля и время. При precision=s передавайте Unix-время измерения в секундах и сохраняйте его при повторе. Широта, долгота, скорость и valid одной GPS-фиксации должны быть в одной строке; отдельные запросы с серверным временем могут создать разные точки.
weather,node=weather1 valid=true,temp_c=24.5,rh=67.2 1790467200
weather,node=weather1 valid=false 1790467260
soil,node=soil1 valid=true,index=42.0 1790467200
gps,vehicle=tractor1,segment=boot1_1 valid=true,lat=51.672,lon=39.2104,speed_kmh=12.5 1790467200
receipt,event=r1 delta_kg=100.0 1790467200
camera_event,camera=cam1,event=c1 count=1i 1790467200
Ниже искусственные данные для знакомства с форматом: замените значения и время перед опытом. temp_c обозначает °C, rh — относительную влажность воздуха, index — условную почвенную шкалу. Настройте источник на эту схему передачи; для существующих записей Telegram или Google Таблицы понадобится свой адаптер.
Попробуйте отправить одну строку повторно. InfluxDB узнаёт точку по измерению, тегам и времени. Неизменная строка оставит одну точку, а другие значения тех же полей заменят прежние. Поэтому при повторе сохраняйте исходное содержимое и время; для реестра операций нужен отдельный порядок хранения.
ESP32 приносит температуру и влажность
Скетч собран для классической ESP32: Arduino-ESP32 3.3.12, DHT sensor library 1.4.7 и Adafruit Unified Sensor. DHT22: питание 3,3 В, общий GND, DATA на GPIO4; голому датчику нужна подтяжка DATA к 3,3 В, например 10 кОм. У готовой платы проверьте схему.
Заполните Wi-Fi, WRITE_URL и токен записи. В SERVER_CA вставьте весь ca.crt с BEGIN/END; NODE допускает строчные латинские буквы, цифры и подчёркивание. Без начальной синхронизации NTP скетч пропускает измерения.
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <DHT.h>
#include <time.h>
const char *SSID="CHANGE_ME", *PASSWORD="CHANGE_ME";
const char *WRITE_URL="https://192.168.1.100:8086/api/v2/write?org=farm&bucket=sensors&precision=s";
const char *TOKEN="CHANGE_ME", *NODE="weather1";
const char *SERVER_CA=R"PEM(-----BEGIN CERTIFICATE-----
PASTE_YOUR_CA_CERTIFICATE
-----END CERTIFICATE-----
)PEM";
DHT dht(4,DHT22);
String queue[32]; unsigned head=0,count=0; uint32_t dropped=0;
uint32_t sampleAt=0,sendAt=0,wifiAt=0; time_t lastStamp=0;
bool fatal=false;
bool validNode(const char *s) {
size_t n=strlen(s); if (!n || n>24) return false;
for (size_t i=0;i<n;i++) if (!((s[i]>='a'&&s[i]<='z') || (s[i]>='0'&&s[i]<='9') || s[i]=='_')) return false;
return true;
}
String point(time_t stamp,float t,float h) {
if (stamp<1735689600 || !validNode(NODE)) return "";
bool ok=isfinite(t)&&isfinite(h)&&t>=-40&&t<=80&&h>=0&&h<=100;
String line="weather,node="+String(NODE)+" valid="+(ok?"true":"false");
if (ok) line+=",temp_c="+String(t,2)+",rh="+String(h,2);
return line+" "+String((long long)stamp);
}
void enqueue(const String &line) {
if (line.isEmpty()) return;
if (count==32) { ++dropped; return; }
queue[(head+count)%32]=line; ++count;
}
int sendPoint(const String &line) {
if (WiFi.status()!=WL_CONNECTED || time(nullptr)<1735689600 || String(TOKEN)=="CHANGE_ME") return 0;
if (!String(WRITE_URL).startsWith("https://")) return 0;
NetworkClientSecure client; client.setCACert(SERVER_CA); client.setHandshakeTimeout(5);
HTTPClient http; http.setConnectTimeout(3000); http.setTimeout(5000);
http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
if (!http.begin(client,WRITE_URL)) return 0;
http.addHeader("Authorization","Token "+String(TOKEN));
http.addHeader("Content-Type","text/plain; charset=utf-8");
int status=http.POST(line); http.end(); return status;
}
void setup() {
Serial.begin(115200); dht.begin(); WiFi.mode(WIFI_STA); WiFi.begin(SSID,PASSWORD);
configTime(0,0,"pool.ntp.org","time.cloudflare.com");
fatal=!validNode(NODE);
}
void loop() {
uint32_t now=millis();
if (WiFi.status()!=WL_CONNECTED && uint32_t(now-wifiAt)>=30000) {
wifiAt=now; WiFi.disconnect(); WiFi.begin(SSID,PASSWORD);
}
if (uint32_t(now-sampleAt)>=60000) {
sampleAt=now; time_t stamp=time(nullptr);
if (stamp>=1735689600 && stamp>lastStamp) {
lastStamp=stamp; float t=dht.readTemperature(),h=dht.readHumidity();
enqueue(point(stamp,t,h));
} else Serial.println("No new UTC timestamp; measurement skipped");
Serial.printf("pending=%u dropped=%lu fatal=%d\n",count,(unsigned long)dropped,fatal);
}
if (!fatal && count && uint32_t(now-sendAt)>=10000) {
sendAt=now; int status=sendPoint(queue[head]);
if (status==204) { queue[head]=""; head=(head+1)%32; --count; }
else if (status>=400 && status<500 && status!=408 && status!=429) {
fatal=true; Serial.printf("Configuration/data rejected: HTTP %d\n",status);
}
}
delay(10);
}
Плата измеряет раз в минуту и отмечает ошибку DHT как valid=false. Отключите сеть и понаблюдайте за очередью: в RAM помещаются 32 строки, затем новые отбрасываются со счётчиком dropped. Перезапуск очищает её. Для долгих отключений можно добавить постоянную очередь на устройстве или шлюзе.
Только HTTP 204 освобождает запись. Сеть, 408, 429 и 5xx повторяются через 10 секунд; другие 4xx блокируют отправку до исправления и перезапуска. Смотрите fatal в Serial Monitor на 115200 бод. Ожидание HTTPS задерживает цикл: этот код нельзя ставить в контур насоса или аварийной защиты.
В существующем устройстве добавляйте отправку независимо от управления и прежних оповещений. Основа измерений есть в статье о метеостанции, а калибровка почвенного индекса — во введении в IoT.
Собираем панель по одному вопросу за раз
Создайте Dashboard с источником InfluxDB и выберите часовой пояс хозяйства. Для истории меняйте диапазон: Grafana подставляет v.timeRangeStart, v.timeRangeStop и v.windowPeriod. Панели «сейчас» ниже отдельно ограничены последними пятью минутами — удобно сравнить прошлое с текущим состоянием.
Как нагревалась теплица
Для Time series используйте запрос температуры:
from(bucket: "sensors")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "weather" and r.node == "weather1")
|> filter(fn: (r) => r._field == "temp_c" or r._field == "valid")
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> filter(fn: (r) => exists r.valid and r.valid and exists r.temp_c)
|> map(fn: (r) => ({r with _value: r.temp_c}))
|> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)
Для влажности второй запрос получается заменой temp_c на rh. Настройте °C и percent (0–100), влажность — на правой оси; Connect null values — Never. Среднее сглаживает краткий холод, поэтому для тревог используем последнее свежее измерение.
Что сейчас показывает почвенный датчик
Gauge: шкала 0–100, единицы none, заголовок «Индекс soil1, не старше 5 минут». Сначала выбирается последняя запись, затем проверяется качество: новая ошибка не оставляет прежнее хорошее показание.
from(bucket: "sensors")
|> range(start: -5m)
|> filter(fn: (r) => r._measurement == "soil" and r.node == "soil1")
|> filter(fn: (r) => r._field == "index" or r._field == "valid")
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> group()
|> sort(columns: ["_time"])
|> tail(n: 1)
|> filter(fn: (r) => exists r.valid and r.valid and exists r.index)
|> map(fn: (r) => ({_time: r._time, _value: r.index}))
Для пустого результата задайте подпись «Нет свежих данных». Цветовые пороги выберите по своему грунту и условной шкале. Если сигнал заметно сдвинулся, вернитесь к датчику и калибровке: график подскажет, где начать проверку.
Сколько урожая приняли сегодня
Суммируйте подтверждённые события приёмки, не каждое считывание весов. Событие receipt имеет неизменные event и время, исправление — отдельный delta_kg со знаком. Тест 100 + 50 − 10 = 140 кг сохраняет сумму после повторной доставки тех же строк.
import "timezone"
import "date"
option location = timezone.location(name: "Europe/Moscow")
from(bucket: "sensors")
|> range(start: date.truncate(t: now(), unit: 1d))
|> filter(fn: (r) => r._measurement == "receipt" and r._field == "delta_kg")
|> group()
|> sum()
Выберите Stat, единицы kg и укажите часовой пояс в заголовке. В примере день начинается по Europe/Moscow. Если результат пустой, проверьте поступление событий. Для постоянного учёта сохраняйте надёжный реестр и сверяйте итог; теги отдельных событий также учитывайте при оценке числа рядов.
Где прошла техника
Используйте встроенную Geomap: отдельный плагин для неё не нужен. Запрос собирает поля одной фиксации и разделяет участки маршрута по тегу segment:
from(bucket: "sensors")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "gps" and r.vehicle == "tractor1")
|> filter(fn: (r) => r._field == "lat" or r._field == "lon" or r._field == "speed_kmh" or r._field == "valid")
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> filter(fn: (r) => exists r.valid and r.valid and exists r.lat and exists r.lon)
|> group(columns: ["vehicle", "segment"])
|> sort(columns: ["_time"])
В Markers задайте lat/lon и speed_kmh в подсказке. Это история выбранного периода. Для Route сохраняйте отдельный ряд каждого segment и порядок по времени. Источник начинает новый segment после перезапуска и потери фиксации; Merge может соединить разрывы ложной линией.
Источник проверяет диапазоны и качество GPS, при потере фиксации пишет valid=false без координат. Такой адаптер не включён автоматически в трекер с Google Таблицей. Для панели «сейчас» ограничьте свежесть и покажите время фиксации. Подложка карты может требовать интернет и соблюдения условий поставщика.
Когда срабатывала камера
Для Time series или столбчатого графика подсчитайте события camera_event. У каждого события должны сохраняться свои event и время при повторе:
from(bucket: "sensors")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
|> filter(fn: (r) => r._measurement == "camera_event" and r.camera == "cam1" and r._field == "count")
|> group(columns: ["camera"])
|> aggregateWindow(every: 1h, fn: sum, createEmpty: false)
График покажет срабатывания камеры. Сами снимки удобно держать в защищённом архиве или Telegram. Если захотите разместить их на карте, слой Photos принимает URL: отдельно настройте загрузку, доступ и срок ссылки на изображение.
Пусть график позовёт вас при изменении
Создайте Grafana-managed alert rule с запросом A: он берёт последнее качественное измерение за пять минут. Затем попробуйте ошибку и отключение датчика, чтобы познакомиться с состояниями отсутствия данных.
from(bucket: "sensors")
|> range(start: -5m)
|> filter(fn: (r) => r._measurement == "weather" and r.node == "weather1")
|> filter(fn: (r) => r._field == "temp_c" or r._field == "valid")
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> group()
|> sort(columns: ["_time"])
|> tail(n: 1)
|> filter(fn: (r) => exists r.valid and r.valid and exists r.temp_c)
|> map(fn: (r) => ({_time: r._time, _value: r.temp_c}))
Добавьте Reduce → Last и Threshold → Is below 0. Для опыта задайте проверку раз в минуту и ожидание две минуты. Посмотрите на задержку срабатывания; рабочие пороги и время реакции выберите по задаче. Для оценки условий по всему участку понадобятся наблюдения в соответствующих местах.
Сохраните отдельные состояния No Data и Error и направьте их уведомления, включая DatasourceNoData/DatasourceError. Проверьте отключение датчика и базы. Normal и Keep Last State могут скрыть отсутствие свежих данных.
Создайте Telegram contact point по инструкции Grafana и назначьте его в notification policy. Получатель должен начать диалог с ботом. Проверьте тест и переход «тревога → восстановление»; ответ API не означает прочтения. Для ответственных задач нужен независимый контроль отказа сервера.
Смотрим издалека и сохраняем историю
VPN хозяйства должен обеспечивать достижимость сервера, маршруты и доверие к сертификату. Одно приложение WireGuard не решает NAT. Для просмотра нужна только Grafana с ограниченной учётной записью; InfluxDB наружу не открывайте.
Для Cloudflare Tunnel используйте официальную процедуру: управляемый туннель, connector своей архитектуры, hostname Grafana. До публикации настройте Cloudflare Access с разрешёнными пользователями, доверенный origin CA и имя сервера; проверьте отказ постороннему. Это внешний провайдер, его условия и доступность проверяются отдельно.
В примере история хранится 30 дней, после чего retention удаляет старые данные. Следите за диском и ошибками, копии сохраняйте отдельно. Если нужны длительные сводки, заведите другой bucket и задачу: вместе со средним полезно оставить минимум и максимум, чтобы видеть отклонения.
Сохраняйте копии на другом носителе или в защищённом хранилище. На доверенном компьютере с influx CLI и ca.crt выполните Bash-команды ниже. Токен вводится скрыто; каталог backup должен быть новым:
read -rsp 'Operator token: ' INFLUX_TOKEN
printf '\n'
export INFLUX_TOKEN
export INFLUX_HOST='https://192.168.1.100:8086'
export SSL_CERT_FILE="$PWD/ca.crt"
umask 077
influx backup ./backup
unset INFLUX_TOKEN
На отдельном тестовом экземпляре совместимой версии проверьте восстановление в новый bucket. Повторите ввод токена и export выше с адресом, CA и токеном тестового экземпляра, перенесите копию и выполните:
influx restore --org farm --bucket sensors --new-bucket sensors_check ./backup
Сравните точки, время и контрольные суммы. Для полной замены сервера следуйте процедуре restore: InfluxDB 2.9 с хешированными токенами требует сохранённого операторского токена. Отдельно копируйте Grafana, панели, оповещения и сертификаты. Базе нужен записываемый том; read-only корневой раздел этого не заменяет.
Проверяем панель на знакомых событиях
Пройдите путь от свежего сигнала до одной точки, единиц, тревоги и восстановления. Затем попробуйте повтор, отключение сети, valid=false, задержку и No Data. Сравните GPS с известным маршрутом, приёмки — с документами. Тогда у каждого числа на экране будет понятное происхождение.
Первый полезный результат — панель, по которой можно объяснить события дня. Отметьте полив, открытие теплицы или проход техники и найдите соответствующее место в истории. Затем выберите следующий вопрос, на который нужны данные.
В расходы включите сервер, резервное хранение и обслуживание. Электроэнергию посчитайте по измеренной мощности в кВт, времени работы и своему тарифу. Пользу оценивайте по действиям, которые удалось изменить благодаря наблюдениям.
Для нескольких площадок согласуйте схему данных, время, права и доставку после разрывов. Каждому показателю задайте ясный смысл: GPS даёт положение и скорость, для моточасов и расхода топлива требуются свои данные; почвенный индекс помогает выбрать место дальнейшей проверки.
Шаблоны Grafana проверяйте по полям и единицам. Визуализация не заменяет учёт партий и исправлений. На сайте «ТерраКвант» описаны инструменты учёта и подключения оборудования; интеграцию своей схемы нужно проверить отдельно.





