- KFS × Navera
- IoT & AI for Smart Agriculture
- السيشن 20
الصيانة والتحسين وقابلية التوسع
Maintenance, Optimization & Scalability
نظام يعمل في الحقل موسماً كاملاً يحتاج صيانة: مراقبة صحة العقد (Heartbeat، الذاكرة، الإشارة، سبب إعادة التشغيل، البطارية)، تحديث الـ firmware لاسلكياً OTA دون فك العقد، النسخ الاحتياطي وأرشفة البيانات، وإضافة عقد جديدة بسهولة — مع اختبار حمل لـ 50 عقدة.
نظرة عامة على السيشن
بنهاية السيشن ستكون قادراً على
- تصمم رسالة Health / Heartbeat وتراقب مؤشرات صحة العقدة.
- تحدّث الـ firmware لاسلكياً بـ ArduinoOTA بأمان (كلمة سر، إيقاف المشغلات، Versioning).
- تستخدم MQTT Retain لتعرف آخر حالة لكل عقدة فوراً.
- تنفذ Backups و Retention / Downsampling وتختبر الاستعادة.
- تضيف عقداً جديدة بإجراء موحد (Provisioning) وتختبر الحمل.
التطبيق في مزرعة الكلية
جولة صيانة حقيقية في المزرعة: فحص صحة كل عقدة من اللوحة وبيانات Health، استبدال البطاريات الضعيفة، تحديث firmware عقد المزرعة لاسلكياً دون فصلها من أماكنها، وإضافة عقدة جديدة للشبكة خلال 10 دقائق.
المُخرج (Deliverable)
نظام مستقر قابل للتوسع: كل العقد على آخر إصدار عبر OTA، رسائل Health محفوظة (Retained)، نسخ احتياطي يومي مجدول مع أرشفة، ودليل «إضافة عقدة جديدة» موثق ومجرب.
خطة السيشن (180 دقيقة)
- 0:00 مراجعة مراجعة: تقرير الموسم والخرائط 10 د
- 0:10 شرح مراقبة صحة العقد وخطة الصيانة 25 د
- 0:35 تطبيق Lab: التحديث اللاسلكي OTA + الإصدارات 30 د
- 1:05 استراحة استراحة 10 د
- 1:15 تطبيق Lab: النسخ الاحتياطي والأرشفة واختبار الاستعادة 30 د
- 1:45 تطبيق التوسع: إضافة عقد، اختبار حمل 50 عقدة 25 د
- 2:10 ميداني ميداني: جولة الصيانة + OTA لعقد المزرعة 40 د
- 2:50 تقييم اختبار سريع + الواجب 10 د
الأدوات والمكونات
قاموس المصطلحات (Glossary)
Over-The-Air — رفع firmware جديد عبر Wi-Fi.
رقم مثل 1.4.0 (Major.Minor.Patch) لتتبع ما على كل عقدة.
رسالة دورية: uptime، heap، RSSI، سبب إعادة التشغيل.
تناقصها المستمر = تسريب ذاكرة (Memory leak).
طاقة، Watchdog، Brownout، Panic — يكشف الأعطال.
الـ Broker يعطي آخر Health لأي مشترك جديد.
نسخة من البيانات في مكان آخر.
النسخة الاحتياطية غير المختبرة ليست نسخة.
كم نحتفظ بالبيانات الخام قبل أرشفتها.
خطوات موحدة لإدخال عقدة جديدة للشبكة.
محاكاة عدد كبير من العقد لاختبار السيرفر.
مؤشر موثوقية العقدة.
صحة العقد وخطة الصيانة
25 دقيقة| المؤشر | طبيعي | إنذار | السبب المحتمل |
|---|---|---|---|
| uptime | يزيد باستمرار | يعود للصفر كثيراً | إعادة تشغيل متكررة |
| reset_reason | POWERON / DEEPSLEEP | BROWNOUT / PANIC / WDT | بطارية ضعيفة / Bug / تعليق |
| free heap | ثابت تقريباً | يتناقص كل ساعة | تسريب ذاكرة (String في loop) |
| RSSI | > −70 dBm | < −80 dBm | نمو النبات، تحرك العقدة، عائق جديد |
| battery_v | > 3.7 V | < 3.5 V | وقت الاستبدال / اللوح الشمسي متسخ |
| soil reading | يتغير بعد الري | ثابت تماماً أيام | الحساس خرج من التربة أو تآكل |
| الدورية | المهمة |
|---|---|
| يومياً (آلي) | نسخة احتياطية، فحص العقد غير المتصلة، تنبيه البطارية |
| أسبوعياً | مراجعة لوحة الصحة، تنظيف ألواح الشمس، نسخ الـ Backups لقرص خارجي |
| شهرياً | فحص العلب (رطوبة، نمل)، إعادة معايرة حساس واحد على الأقل، تحديث firmware إن لزم |
| كل موسم | استبدال الحساسات المتآكلة، إعادة ترتيب العقد حسب المحصول الجديد، أرشفة البيانات |
Lab: التحديث اللاسلكي OTA
30 دقيقة/*
Session 20 — Over-The-Air (OTA) firmware updates + health heartbeat (ESP32)
After the first USB upload, the node appears in Arduino IDE -> Tools -> Port -> "kfs-field-a at 192.168.1.x".
Upload again over Wi-Fi without removing the node from the field. The OTA password protects it.
Heartbeat: every 5 min publish uptime, free heap, RSSI, reset reason, firmware version (retained MQTT).
*/
#include <WiFi.h>
#include <ArduinoOTA.h>
#include <PubSubClient.h>
#include <esp_system.h>
#define FW_VERSION "1.4.0"
const char* WIFI_SSID = "KFS-Farm-IoT";
const char* WIFI_PASS = "change-me";
const char* HOSTNAME = "kfs-field-a";
const char* OTA_PASS = "farm-ota-2026";
const char* BROKER = "192.168.1.10";
WiFiClient net;
PubSubClient mqtt(net);
unsigned long lastBeat = 0;
void setup() {
Serial.begin(115200);
WiFi.setHostname(HOSTNAME);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(300);
ArduinoOTA.setHostname(HOSTNAME);
ArduinoOTA.setPassword(OTA_PASS);
ArduinoOTA.onStart([]() { digitalWrite(26, HIGH); Serial.println("OTA start - pump relay forced OFF"); });
ArduinoOTA.onProgress([](unsigned int p, unsigned int t) { Serial.printf("OTA %u%%\r", p * 100 / t); });
ArduinoOTA.onEnd([]() { Serial.println("\nOTA done, rebooting"); });
ArduinoOTA.onError([](ota_error_t e) { Serial.printf("OTA error %u\n", e); });
ArduinoOTA.begin();
mqtt.setServer(BROKER, 1883);
Serial.printf("fw %s ready at %s\n", FW_VERSION, WiFi.localIP().toString().c_str());
}
void loop() {
ArduinoOTA.handle(); // must be called often
if (!mqtt.connected()) mqtt.connect(HOSTNAME);
mqtt.loop();
if (millis() - lastBeat > 300000 || lastBeat == 0) {
lastBeat = millis();
char buf[200];
snprintf(buf, sizeof(buf),
"{\"fw\":\"%s\",\"uptime_s\":%lu,\"heap\":%u,\"rssi\":%d,\"reset_reason\":%d}",
FW_VERSION, millis() / 1000, ESP.getFreeHeap(), WiFi.RSSI(), (int)esp_reset_reason());
mqtt.publish("kfs/farm/field_A/health", buf, true); // retained: the dashboard always sees the last state
}
}
أول تحديث OTA
- الرفع الأول بالـ USB
ارفع الكود مرة واحدة بالكابل (OTA يحتاج كوداً يدعمه أولاً).
- ظهور المنفذ الشبكي
في Arduino IDE:
Tools → Portستجدkfs-field-a at 192.168.1.x(اللابتوب والعقدة على نفس الشبكة). - التحديث
غيّر
FW_VERSIONإلى 1.4.1، اختر المنفذ الشبكي، اضغط Upload، وأدخل كلمة سر OTA. - التحقق
في MQTT Explorer:
kfs/farm/field_A/healthيظهر fw = 1.4.1 و reset_reason يدل على إعادة تشغيل بعد التحديث. - الأمان
لاحظ
onStart: المضخة تُطفأ قبل بدء التحديث — لا نترك مضخة تعمل أثناء إعادة التشغيل.
لا تحرق جسورك
إذا رفعت عبر OTA كوداً لا يحتوي ArduinoOTA.handle() أو يتعلق قبل الاتصال بالواي فاي، فقدت القدرة على التحديث اللاسلكي وستحتاج الذهاب للحقل بالكابل. اختبر كل إصدار على عقدة المعمل أولاً، ثم عقدة واحدة في الحقل، ثم الباقي (Staged rollout).
Lab: النسخ الاحتياطي والأرشفة
30 دقيقة"""Backups + retention / downsampling for farm.db (session 20).
python backup.py # hot backup to backups/farm-YYYYmmdd-HHMM.db, keep the last 14
python backup.py --downsample 90 # move raw readings older than 90 days into hourly averages
python backup.py --verify FILE # open a backup and print its row counts (restore test)
Schedule it daily (cron / Task Scheduler). Copy the backups folder to a second disk or USB weekly.
"""
import argparse
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
import config
BACKUP_DIR = config.BASE_DIR / "backups"
KEEP = 14
def hot_backup():
"""SQLite online backup API: consistent copy even while app.py / mqtt_ingest.py are writing."""
BACKUP_DIR.mkdir(exist_ok=True)
dest = BACKUP_DIR / f"farm-{datetime.now():%Y%m%d-%H%M%S}.db"
src = sqlite3.connect(config.DB_PATH)
dst = sqlite3.connect(dest)
with dst:
src.backup(dst)
src.close(); dst.close()
old = sorted(BACKUP_DIR.glob("farm-*.db"))[:-KEEP]
for f in old:
f.unlink()
print(f"backup -> {dest.name} ({dest.stat().st_size / 1024:.0f} KB), removed {len(old)} old file(s)")
return dest
def downsample(days):
"""Keep full resolution for recent data, hourly averages for the archive (smaller DB, same trends)."""
cutoff = (datetime.now() - timedelta(days=days)).isoformat(timespec="seconds")
con = sqlite3.connect(config.DB_PATH)
con.executescript("""CREATE TABLE IF NOT EXISTS readings_hourly (
node_id TEXT, hour TEXT, air_temp_c REAL, air_humidity_pct REAL, soil_moisture_pct REAL,
light REAL, battery_v REAL, n INTEGER, PRIMARY KEY (node_id, hour));""")
with con:
con.execute("""INSERT OR REPLACE INTO readings_hourly
SELECT node_id, substr(ts, 1, 13) || ':00:00', AVG(air_temp_c), AVG(air_humidity_pct),
AVG(soil_moisture_pct), AVG(light), AVG(battery_v), COUNT(*)
FROM readings WHERE ts < ? GROUP BY node_id, substr(ts, 1, 13)""", (cutoff,))
moved = con.execute("DELETE FROM readings WHERE ts < ?", (cutoff,)).rowcount
con.execute("VACUUM")
con.close()
print(f"downsampled readings older than {days} days: {moved} raw rows -> hourly archive")
def verify(path):
con = sqlite3.connect(path)
for t in ("nodes", "readings", "commands", "decisions"):
n = con.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
print(f"{t:10s} {n:>8d} rows")
print("integrity:", con.execute("PRAGMA integrity_check").fetchone()[0])
con.close()
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--downsample", type=int)
ap.add_argument("--verify")
a = ap.parse_args()
if a.verify:
verify(a.verify)
elif a.downsample:
hot_backup() # always back up before deleting anything
downsample(a.downsample)
else:
hot_backup()
نسخة + أرشفة + اختبار استعادة
قاعدة بها 120 يوماً من قراءات كل 15 دقيقة (11,520 صف). نحتفظ بالخام لآخر 90 يوماً وننقل الأقدم لمتوسطات ساعية. هذه المخرجات الفعلية:
python backup.py --downsample 90
python backup.py --verify backups/farm-20261104-215323.db
المخرجات الفعلية عند التشغيل (Real output)
backup -> farm-20261104-215323.db (1332 KB), removed 0 old file(s) downsampled readings older than 90 days: 2881 raw rows -> hourly archive nodes 3 rows readings 11520 rows commands 0 rows decisions 0 rows integrity: ok
قاعدة 3-2-1 للنسخ الاحتياطي
- 3 نسخ من البيانات (الأصل + نسختان).
- على 2 وسيط مختلف (قرص اللابتوب + USB/قرص خارجي).
- 1 نسخة خارج المزرعة (Google Drive للكلية أو قرص في مكتب القسم).
- واختبر الاستعادة شهرياً — النسخة غير المختبرة ليست نسخة.
لماذا الـ backup API وليس نسخ الملف؟
نسخ farm.db بالمستكشف أثناء كتابة mqtt_ingest قد يعطي ملفاً تالفاً. sqlite3.Connection.backup() يأخذ لقطة متسقة حتى والسيرفر يعمل.
التوسع وإضافة العقد
25 دقيقةدليل إضافة عقدة جديدة (Provisioning) — 10 دقائق
-
المعرف
اختر node_id حسب القاعدة
<zone>_<letter/number>وسجّله في سجل العقد مع MAC. -
المعايرة
Dry/Wet للحساس الجديد (السيشن 2) واكتبهما في الكود.
-
الـ firmware
نفس الإصدار المعتمد + NODE_ID + المعايرة، رفع أول بالـ USB.
-
التسجيل
العقدة تُسجل تلقائياً في جدول nodes مع أول قراءة. حدّث اسمها ومكانها:
UPDATE nodes SET name=…, zone=…, lat=…, lon=…. -
التحقق
تظهر في اللوحة والخريطة و network_status خلال دقيقة.
import json, random, threading, time, urllib.request
URL, KEY = "http://127.0.0.1:5000/api/v1/readings", "kfs-farm-node-key"
lat = []
def node(i, n_msgs=30):
for _ in range(n_msgs):
body = json.dumps({"node_id": f"load_{i:02d}", "air_temp_c": 25 + random.random() * 5,
"soil_moisture_pct": 30 + random.random() * 20}).encode()
req = urllib.request.Request(URL, body, {"Content-Type": "application/json", "X-API-Key": KEY})
t0 = time.perf_counter(); urllib.request.urlopen(req, timeout=10); lat.append(time.perf_counter() - t0)
time.sleep(10)
threads = [threading.Thread(target=node, args=(i,)) for i in range(50)]
[t.start() for t in threads]; [t.join() for t in threads]
lat.sort()
print(f"{len(lat)} requests | median {lat[len(lat)//2]*1000:.0f} ms | p95 {lat[int(len(lat)*.95)]*1000:.0f} ms")
شغّله على منصة تجريبية (قاعدة بيانات منفصلة عبر SF_DB_PATH) — ليس على بيانات المزرعة الحقيقية. 50 عقدة × رسالة كل 10 ث = 5 رسائل/ثانية: حمل بسيط لـ Flask + SQLite (WAL). عند المئات ننتقل لـ gunicorn/waitress و PostgreSQL أو InfluxDB.
MQTT Retain للتوسع
مع 50 عقدة لا نريد انتظار الرسالة القادمة لنعرف حالة كل عقدة. لأن status و health منشوران بـ retain، أي لوحة أو سكربت يشترك الآن يستلم آخر حالة لكل العقد فوراً.
ميداني: جولة الصيانة
40 دقيقةقائمة فحص لكل عقدة (Maintenance checklist)
تحديث firmware عقد المزرعة دون فكها
كل عقد المزرعة في أماكنها + لابتوب على شبكة المزرعة
- قبل
سجّل الإصدار الحالي وحالة Health لكل عقدة.
- عقدة تجريبية
حدّث عقدة واحدة OTA وراقبها 10 دقائق: قراءات طبيعية؟ أوامر المضخة تعمل؟
- الباقي
حدّث باقي العقد واحدة واحدة من مكانك في غرفة المزرعة.
- البطاريات
استبدل أي بطارية < 3.6 V وسجّل التاريخ (لحساب MTBF لاحقاً).
- عقدة جديدة
أضف عقدة جديدة باتباع دليل Provisioning وقس الوقت المستغرق.
| العقدة | إصدار قبل | إصدار بعد | RSSI | بطارية V | إجراء صيانة |
|---|---|---|---|---|---|
- أوقف أوامر الري لكل عقدة قبل تحديثها.
- لا تفتح علبة عقدة أثناء الري.
اختبر فهمك
5 أسئلة · نتيجة فوريةالواجب والتسليم
الواجب الأسبوعي (Weekly Assignment)
جدول النسخ الاحتياطي اليومي (cron / Task Scheduler) + نسخ أسبوعي لقرص خارجي، ووثّق خطوات الاستعادة.
شغّل اختبار الحمل على منصة تجريبية وقدم median و p95 لزمن الاستجابة.
أكمل قائمة فحص الصيانة لكل عقد فريقك وارفعها.
قائمة التسليم (Submission Checklist)
استعد للسيشن القادمة
الأسبوع الأخير! السيشن القادمة تجميع المشروع النهائي واختبار ميداني 48 ساعة. كل فريق يحضر كل مكونات عقدته + علبة + بطارية مشحونة + خطة الاختبار.