استقبال البيانات ومعالجتها على السيرفر
Server-Side Data Reception & Processing
نكتب الحلقة التي تربط الحقل بالسيرفر: سكربت Python يشترك في MQTT، يتحقق من صحة كل رسالة، يرفض القيم المستحيلة، يخزن في SQLite بجدول زمني مفهرس، ويسجل الأخطاء — مع لوحة حالة الشبكة الحية في الطرفية.
نظرة عامة على السيشن
بنهاية السيشن ستكون قادراً على
- تصمم Schema لبيانات سلاسل زمنية في SQLite بفهارس مناسبة.
- تكتب مشترك MQTT بمكتبة paho-mqtt 2.x مع callbacks.
- تطبق Validation: رفض JSON الفاسد، القيم خارج المدى ⇒ NULL، تسجيل العقد الجديدة تلقائياً.
- تستخدم logging لتسجيل الأخطاء بدلاً من print، وتقرأ السجل لتشخيص المشاكل.
- تحسب حالة الشبكة (Online/Offline، نسبة التسليم) باستعلامات SQL.
التطبيق في مزرعة الكلية
تشغيل سيرفر الاستقبال على لابتوب المزرعة طوال الجلسة، وعرض لوحة «حالة الشبكة» لكل عقد المزرعة على الشاشة: آخر ظهور، عدد الرسائل في الساعة، نسبة التسليم، والقيم المرفوضة.
المُخرج (Deliverable)
خط بيانات كامل (Data pipeline) من الحقل إلى السيرفر المحلي: Broker → mqtt_ingest.py → SQLite، مع ملف سجل أخطاء (ingest.log) ولوحة حالة الشبكة.
خطة السيشن (180 دقيقة)
- 0:00 مراجعة مراجعة: شجرة المواضيع واختبار LWT 10 د
- 0:10 شرح معمارية خط البيانات + تصميم جداول السلاسل الزمنية 25 د
- 0:35 تطبيق Lab: SQLite من الصفر — إنشاء، إدخال، استعلامات زمنية 30 د
- 1:05 تطبيق Lab: سكربت الاستقبال mqtt_ingest.py سطراً بسطر 30 د
- 1:35 استراحة استراحة 10 د
- 1:45 تطبيق معالجة البيانات المفقودة والأخطاء + السجلات + لوحة الحالة 30 د
- 2:15 ميداني ميداني: تشغيل خط البيانات الكامل من حقول المزرعة 35 د
- 2:50 تقييم اختبار سريع + الواجب 10 د
الأدوات والمكونات
قبل السيشن
pip install -r requirements.txtداخل مجلد smart-farm.- تثبيت DB Browser for SQLite.
قاموس المصطلحات (Glossary)
سلسلة المراحل من الحساس حتى قاعدة البيانات.
استقبال البيانات وإدخالها للنظام.
أسماء الأعمدة وأنواعها.
يسرّع البحث (مثل فهرس الكتاب) — ضروري على (node_id, ts).
قيم مرتبة بالوقت.
غياب القيمة — أفضل من تخزين −999 أو 0.
دالة تنفذها المكتبة عند حدث (وصول رسالة).
رفض أو تصحيح البيانات غير المنطقية قبل التخزين.
كتابة الأحداث والأخطاء في ملف بوقتها.
يسمح بالقراءة والكتابة على SQLite في نفس الوقت.
INSERT … ON CONFLICT DO UPDATE.
2024-06-12T14:30:00 — ترتيبها النصي = ترتيبها الزمني.
معمارية خط البيانات
25 دقيقةرحلة القراءة من التربة إلى القرص
-
العقدة
تقرأ وتفلتر وتنشر JSON على
kfs/farm/field_A/telemetry. -
Mosquitto
يتحقق من المستخدم والصلاحية ويوزع الرسالة.
-
mqtt_ingest.py
يشترك في
kfs/farm/+/telemetry، يفك JSON، يتحقق، يختم الوقت. -
SQLite (farm.db)
جدول
readingsمفهرس + جدولnodes(آخر ظهور). -
المستهلكون
Flask API ولوحة التحكم (السيشن 10–11) ومحرك التنبيهات (18) والتحليل (13).
"""SQLite storage (time-series friendly schema + indexes). Session 10."""
import sqlite3
from contextlib import contextmanager
from werkzeug.security import generate_password_hash
import config
SCHEMA = """
CREATE TABLE IF NOT EXISTS nodes (
node_id TEXT PRIMARY KEY,
name TEXT,
zone TEXT, -- greenhouse / field
lat REAL,
lon REAL,
last_seen TEXT,
firmware TEXT
);
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
ts TEXT NOT NULL, -- ISO-8601 local time
air_temp_c REAL,
air_humidity_pct REAL,
soil_moisture_pct REAL,
light INTEGER,
battery_v REAL,
rssi INTEGER
);
CREATE INDEX IF NOT EXISTS ix_readings_node_ts ON readings (node_id, ts);
CREATE INDEX IF NOT EXISTS ix_readings_ts ON readings (ts);
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL,
actuator TEXT NOT NULL, -- pump | fan
action TEXT NOT NULL, -- on | off
duration_min INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending', -- pending -> delivered -> done | expired
source TEXT DEFAULT 'manual', -- manual | rule | ml
created_by TEXT,
created_at TEXT,
delivered_at TEXT,
acked_at TEXT
);
CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT,
node_id TEXT,
source TEXT, -- rule | ml | manual | system
level TEXT, -- info | warning | critical
message TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
password_hash TEXT
);
"""
DEFAULT_NODES = [
("greenhouse_01", "الصوبة التجريبية", "greenhouse", 31.1052, 30.9431),
("field_A", "الحقل A — خضر", "field", 31.1047, 30.9442),
("field_B", "الحقل B — ذرة", "field", 31.1041, 30.9436),
]
@contextmanager
def connect():
con = sqlite3.connect(config.DB_PATH, timeout=10)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL") # readers don't block the MQTT writer
try:
yield con
con.commit()
finally:
con.close()
def init_db():
with connect() as con:
con.executescript(SCHEMA)
for node in DEFAULT_NODES:
con.execute("INSERT OR IGNORE INTO nodes (node_id, name, zone, lat, lon) VALUES (?,?,?,?,?)", node)
if not con.execute("SELECT 1 FROM users WHERE username=?", (config.ADMIN_USER,)).fetchone():
con.execute("INSERT INTO users (username, password_hash) VALUES (?,?)",
(config.ADMIN_USER, generate_password_hash(config.ADMIN_PASSWORD)))
def rows(sql, params=()):
with connect() as con:
return [dict(r) for r in con.execute(sql, params).fetchall()]
def execute(sql, params=()):
with connect() as con:
cur = con.execute(sql, params)
return cur.lastrowid
def log_decision(ts, node_id, source, level, message):
execute("INSERT INTO decisions (ts, node_id, source, level, message) VALUES (?,?,?,?,?)",
(ts, node_id, source, level, message))
لاحظ: الوقت مخزن كنص ISO-8601 — الترتيب الأبجدي له هو نفسه الترتيب الزمني، فتعمل WHERE ts >= ? و ORDER BY ts مباشرة. والفهرس المركب (node_id, ts) يجعل استعلام «آخر 24 ساعة لعقدة» فورياً حتى مع ملايين الصفوف.
لماذا SQLite وليس InfluxDB الآن؟
SQLite ملف واحد بدون تثبيت، ويتحمل بسهولة عقد المزرعة (4 عقد × 1440 قراءة/يوم = 2 مليون صف في سنة). في السيشن 10 نعرض InfluxDB كقاعدة سلاسل زمنية متخصصة للمزارع الأكبر.
Lab: SQLite من الصفر
30 دقيقةأول قاعدة بيانات للمزرعة في Python
- أنشئ وأدخل
شغّل الكود التالي في ملف
sql_lab.py. - افتح بالـ GUI
افتح farm_lab.db في DB Browser for SQLite وتصفح الجدول.
- استعلامات زراعية
نفذ الاستعلامات الثلاثة في التحدي أسفل الكود.
import math, random, sqlite3
from datetime import datetime, timedelta
con = sqlite3.connect("farm_lab.db")
con.execute("""CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL, ts TEXT NOT NULL,
air_temp_c REAL, soil_moisture_pct REAL)""")
con.execute("CREATE INDEX IF NOT EXISTS ix_node_ts ON readings (node_id, ts)")
start = datetime(2024, 6, 1)
rows = []
for node, base in [("greenhouse_01", 45), ("field_A", 30), ("field_B", 22)]:
soil = base
for i in range(96 * 3): # 3 days, every 15 min
ts = start + timedelta(minutes=15 * i)
temp = 26 + 7 * math.sin((ts.hour - 9) / 24 * 6.283) + random.gauss(0, .4)
soil = soil - 0.12 if soil > 15 else base # slow drying, then "irrigation"
rows.append((node, ts.isoformat(timespec="seconds"), round(temp, 1), round(soil, 1)))
con.executemany("INSERT INTO readings (node_id, ts, air_temp_c, soil_moisture_pct) VALUES (?,?,?,?)", rows)
con.commit()
# latest reading per node
for r in con.execute("""SELECT node_id, MAX(ts), soil_moisture_pct FROM readings GROUP BY node_id"""):
print(r)
# daily average per node (substr(ts,1,10) = the date)
for r in con.execute("""SELECT node_id, substr(ts,1,10) AS day, ROUND(AVG(air_temp_c),1), ROUND(MIN(soil_moisture_pct),1)
FROM readings GROUP BY node_id, day ORDER BY day"""):
print(r)
ثلاثة أسئلة زراعية بالـ SQL
- في أي ساعة من اليوم تكون الحرارة أعلى في المتوسط؟
- كم قراءة في field_B كانت رطوبة التربة فيها أقل من 20%؟
- ما أعلى حرارة سُجلت في الصوبة ومتى؟
الحل (Solution)
-- 1) hottest hour of the day
SELECT substr(ts, 12, 2) AS hour, ROUND(AVG(air_temp_c), 1) AS t
FROM readings GROUP BY hour ORDER BY t DESC LIMIT 1;
-- 2) dry readings in field_B
SELECT COUNT(*) FROM readings WHERE node_id = 'field_B' AND soil_moisture_pct < 20;
-- 3) greenhouse max temperature and when
SELECT ts, air_temp_c FROM readings WHERE node_id = 'greenhouse_01' ORDER BY air_temp_c DESC LIMIT 1;
Lab: سكربت الاستقبال
30 دقيقة"""MQTT -> SQLite bridge (sessions 7-8).
Nodes publish JSON to kfs/farm/<node_id>/telemetry on the local Mosquitto broker.
Run: python mqtt_ingest.py --broker 192.168.1.10
Needs: pip install paho-mqtt>=2.0
"""
import argparse
import json
import logging
from datetime import datetime
import paho.mqtt.client as mqtt
import db
logging.basicConfig(filename="ingest.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
FIELDS = ["air_temp_c", "air_humidity_pct", "soil_moisture_pct", "light", "battery_v", "rssi"]
LIMITS = {"air_temp_c": (-10, 60), "air_humidity_pct": (0, 100), "soil_moisture_pct": (0, 100)}
stats = {"ok": 0, "bad": 0}
def clean(payload):
out = {}
for f in FIELDS:
v = payload.get(f)
try:
v = float(v) if v is not None else None
except (TypeError, ValueError):
v = None
lo, hi = LIMITS.get(f, (-1e9, 1e9))
out[f] = v if v is not None and lo <= v <= hi else None # missing / -999 -> NULL
return out
def on_connect(client, userdata, flags, reason_code, properties=None):
print("connected:", reason_code)
client.subscribe("kfs/farm/+/telemetry", qos=1)
client.subscribe("kfs/farm/+/status", qos=1)
def on_message(client, userdata, msg):
node_id = msg.topic.split("/")[2]
try:
payload = json.loads(msg.payload.decode())
except ValueError:
stats["bad"] += 1
logging.warning("invalid JSON from %s: %r", node_id, msg.payload[:80])
return
if msg.topic.endswith("/status"): # LWT: "offline" published by the broker if the node dies
logging.info("status %s -> %s", node_id, payload)
return
ts = payload.get("ts") or datetime.now().isoformat(timespec="seconds")
v = clean(payload)
db.execute("INSERT INTO readings (node_id, ts, " + ", ".join(FIELDS) + ") VALUES (?,?,?,?,?,?,?,?)",
(node_id, ts, *[v[f] for f in FIELDS]))
db.execute("INSERT INTO nodes (node_id, name, zone, last_seen) VALUES (?,?,?,?) "
"ON CONFLICT(node_id) DO UPDATE SET last_seen=excluded.last_seen", (node_id, node_id, "field", ts))
stats["ok"] += 1
if stats["ok"] % 20 == 0:
print(f"stored {stats['ok']} readings ({stats['bad']} rejected)")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--broker", default="localhost")
ap.add_argument("--port", type=int, default=1883)
ap.add_argument("--user"); ap.add_argument("--password")
a = ap.parse_args()
db.init_db()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="kfs-ingest")
if a.user:
client.username_pw_set(a.user, a.password)
client.on_connect, client.on_message = on_connect, on_message
client.connect(a.broker, a.port, keepalive=60)
client.loop_forever()
| الجزء | الوظيفة |
|---|---|
on_connect | عند الاتصال (أو إعادة الاتصال!) يشترك في المواضيع — لهذا الاشتراك هنا وليس قبل connect. |
on_message | يستخرج node_id من الموضوع، يفك JSON، يرفض الفاسد ويسجله، ثم يدخل في الجدول ويحدث last_seen. |
clean() | يحول كل قيمة لرقم، والقيم خارج الحدود الفيزيائية (−999 من DHT22) تصبح NULL. |
ON CONFLICT … DO UPDATE | Upsert: يضيف العقدة الجديدة تلقائياً أو يحدث آخر ظهور. |
loop_forever() | يدير الشبكة وإعادة الاتصال تلقائياً. |
شغّل خط البيانات على جهازك
Broker من السيشن 7 + سكربت الاستقبال + لوحة الحالة في نافذة ثالثة. إن لم يتوفر Broker يمكن تجربة المسار نفسه عبر HTTP بمحاكي العقد.
cd smart-farm
pip install -r requirements.txt
python mqtt_ingest.py --broker localhost --user dashboard --password ***
# window 2
python network_status.py
# no hardware? use HTTP + virtual nodes instead:
python app.py & python simulate_nodes.py
الأخطاء والقيم المفقودة والسجلات
30 دقيقة| الحالة | مثال | سياستنا |
|---|---|---|
| قيمة مستحيلة | "air_temp_c": -999 | خزّن NULL (لا تخترع قيمة) |
| حقل غائب | DHT22 فشل فلم يرسل air_temp_c | NULL |
| JSON فاسد | رسالة مقطوعة | ارفض + سجل warning في ingest.log |
| عقدة جديدة غير مسجلة | node_07 | سجلها تلقائياً وراجعها يدوياً |
| رسالة مكررة | QoS 1 أعاد الإرسال | نقبلها الآن وننظفها في السيشن 14 (أو مفتاح فريد) |
| فجوة زمنية | انقطاع Wi-Fi 7 ساعات | لا نملأها عند الاستقبال — التحليل يقرر |
أخطر خطأ في بيانات المزارع
تحويل القيمة المفقودة إلى 0. رطوبة تربة = 0% تعني «تربة جافة تماماً» — وقد تجعل محرك الأتمتة يشغّل المضخة! القيمة المفقودة يجب أن تبقى «مفقودة» (NULL/NaN).
"""Live network status board in the terminal (session 8).
python network_status.py # refresh every 10 s
Shows, per node: last seen, readings in the last hour, expected vs received (delivery %), last values,
plus how many values were rejected as sensor errors (stored as NULL).
"""
import os
import sqlite3
import sys
import time
from datetime import datetime, timedelta
import config
EXPECTED_PER_HOUR = int(os.environ.get("SF_EXPECTED_PER_HOUR", "60")) # node sends every 60 s
def board():
con = sqlite3.connect(config.DB_PATH)
con.row_factory = sqlite3.Row
hour_ago = (datetime.now() - timedelta(hours=1)).isoformat(timespec="seconds")
rows = con.execute("""
SELECT n.node_id, n.last_seen,
(SELECT COUNT(*) FROM readings r WHERE r.node_id = n.node_id AND r.ts >= ?) AS last_hour,
(SELECT COUNT(*) FROM readings r WHERE r.node_id = n.node_id AND r.ts >= ?
AND (r.air_temp_c IS NULL OR r.soil_moisture_pct IS NULL)) AS with_nulls,
(SELECT soil_moisture_pct FROM readings r WHERE r.node_id = n.node_id ORDER BY ts DESC LIMIT 1) AS soil,
(SELECT air_temp_c FROM readings r WHERE r.node_id = n.node_id ORDER BY ts DESC LIMIT 1) AS temp
FROM nodes n ORDER BY n.node_id""", (hour_ago, hour_ago)).fetchall()
con.close()
print(f"\nKFS Smart Farm - network status {datetime.now():%Y-%m-%d %H:%M:%S}")
print(f"{'node':15s} {'state':8s} {'last seen':>10s} {'1h msgs':>8s} {'delivery':>9s} {'nulls':>6s} {'soil%':>6s} {'temp':>6s}")
for r in rows:
if r["last_seen"]:
age = (datetime.now() - datetime.fromisoformat(r["last_seen"])).total_seconds()
seen = f"{int(age // 60)}m{int(age % 60):02d}s"
state = "ONLINE" if age < config.NODE_OFFLINE_MIN * 60 else "OFFLINE"
else:
seen, state = "never", "UNKNOWN"
pdr = min(100, r["last_hour"] / EXPECTED_PER_HOUR * 100)
print(f"{r['node_id']:15s} {state:8s} {seen:>10s} {r['last_hour']:>8d} {pdr:>8.0f}% {r['with_nulls']:>6d} "
f"{(r['soil'] if r['soil'] is not None else float('nan')):>6.1f} {(r['temp'] if r['temp'] is not None else float('nan')):>6.1f}")
if __name__ == "__main__":
while True:
board()
if "--once" in sys.argv:
break
time.sleep(10)
اختبار السكربت برسائل سليمة وفاسدة
أرسلنا 5 رسائل للدالة on_message: قراءة سليمة، قراءة بها −999، JSON فاسد، قيمة نصية، ورسالة status. هذه المخرجات الفعلية:
المخرجات الفعلية عند التشغيل (Real output)
[{'node_id': 'field_A', 'air_temp_c': 29.5, 'soil_moisture_pct': 33.0},
{'node_id': 'field_A', 'air_temp_c': None, 'soil_moisture_pct': 34.0},
{'node_id': 'node_07', 'air_temp_c': None, 'soil_moisture_pct': 20.0}]
stats {'ok': 3, 'bad': 1}
node state last seen 1h msgs delivery nulls soil% temp
field_A ONLINE 0m01s 2 3% 1 34.0 nan
field_B UNKNOWN never 0 0% 0 nan nan
node_07 ONLINE 0m00s 1 2% 1 20.0 nanميداني: خط البيانات الكامل
35 دقيقةمن التربة إلى قاعدة البيانات — حياً
لابتوب المزرعة (Broker + ingest + status board على الشاشة) + كل العقد في مواقعها
- التشغيل
Mosquitto ثم mqtt_ingest.py ثم network_status.py — وكل الفرق تشغل عقدها.
- المراقبة 15 دقيقة
راقبوا لوحة الحالة: هل كل العقد ONLINE؟ ما نسبة التسليم؟
- حقن عطل
فريق واحد يفصل حساس DHT22 من عقدته: يجب أن يظهر عمود nulls يزيد بينما تستمر قراءة التربة.
- انقطاع
فريق آخر يطفئ عقدته 5 دقائق: يجب أن تتحول OFFLINE بعد المهلة ثم تعود.
- التحقق
افتحوا farm.db في DB Browser ونفذوا: آخر قراءة لكل عقدة، وعدد القيم NULL.
| العقدة | رسائل / 15 د | nulls | ظهرت OFFLINE؟ | عادت ONLINE؟ |
|---|---|---|---|---|
- اللابتوب متصل بالكهرباء عبر وصلة آمنة بعيداً عن الري.
اختبر فهمك
5 أسئلة · نتيجة فوريةالواجب والتسليم
الواجب الأسبوعي (Weekly Assignment)
أضف جدولاً جديداً
raw_messagesيخزن كل رسالة خام (حتى المرفوضة) مع سبب الرفض — مفيد للتدقيق (Audit).اكتب استعلام SQL يحسب لكل عقدة: عدد الرسائل لكل ساعة خلال آخر 24 ساعة.
صدّر 24 ساعة من farm.db إلى CSV (DB Browser → Export) — سنحتاجها في السيشن 13.
قائمة التسليم (Submission Checklist)
استعد للسيشن القادمة
بدأنا مرحلة الويب! ثبّت VS Code وإضافة Live Server قبل السيشن القادمة.