- KFS × Navera
- IoT & AI for Smart Agriculture
- السيشن 11
بناء لوحة تحكم تفاعلية
Building an Interactive Dashboard
مبادئ تصميم لوحات المراقبة الزراعية: ماذا نعرض ولمن، الرسوم البيانية والمؤشرات (Gauges) والجداول، والتحديث التلقائي بالـ Polling أو لحظياً عبر MQTT over WebSocket — ونطور لوحة KFS Smart Farm لتعرض مناخ المزرعة آخر 7 أيام.
نظرة عامة على السيشن
بنهاية السيشن ستكون قادراً على
- تطبق مبادئ Dashboard design: الأهم أولاً، ألوان دلالية، لا ازدحام.
- تختار عنصر العرض المناسب: Graph للاتجاه، Gauge للحالة الآن، Table للمقارنة.
- تفهم كود لوحة Smart Farm (Fetch + Chart.js + تحديث كل 15 ث) وتعدله.
- تقارن Polling و WebSocket / MQTT over Web للتحديث اللحظي.
- تعرف متى تستخدم أداة جاهزة مثل Grafana بدلاً من لوحة مخصصة.
التطبيق في مزرعة الكلية
لوحة تحكم على شاشة غرفة المزرعة تعرض حالة كل عقدة لحظياً، ومناخ المزرعة آخر 7 أيام (حرارة، رطوبة، رطوبة تربة)، وجدول المتوسطات اليومية — مع اختبار سهولة الاستخدام مع مشرف المزرعة.
المُخرج (Deliverable)
لوحة تحكم مرئية قابلة للمشاركة: بطاقات العقد، رسم 24 ساعة / 7 أيام، جدول يومي، مؤشر Gauge لرطوبة التربة، وتحديث لحظي عبر WebSocket.
خطة السيشن (180 دقيقة)
- 0:00 مراجعة مراجعة: الـ API والاستعلامات الزمنية 10 د
- 0:10 شرح تصميم لوحات المراقبة الزراعية 25 د
- 0:35 تطبيق Lab: لوحة Smart Farm من الداخل + عرض 7 أيام 35 د
- 1:10 استراحة استراحة 10 د
- 1:20 تطبيق Lab: تحديث لحظي عبر MQTT over WebSocket 30 د
- 1:50 مشروع تحدي: Gauge + جدول المتوسطات اليومية 25 د
- 2:15 ميداني ميداني: اللوحة في غرفة المزرعة + اختبار مع المشرف 35 د
- 2:50 تقييم اختبار سريع + الواجب 10 د
الأدوات والمكونات
قاموس المصطلحات (Glossary)
شاشة واحدة تلخص حالة النظام.
رقم مهم يُتابع: متوسط رطوبة التربة، ساعات الري.
عرض قيمة حالية مقابل نطاق (مثل عداد السرعة).
24 ساعة / 7 أيام / 30 يوماً.
المتصفح يسأل السيرفر كل X ثانية.
اتصال مفتوح دائماً يدفع البيانات فور حدوثها.
المتصفح نفسه يصبح عميل MQTT (منفذ 9001).
تحديث البيانات بدون تدخل المستخدم.
أحمر = جاف/خطر، أخضر = مناسب — ثابت في كل اللوحة.
أداة لوحات مفتوحة المصدر تتصل بقواعد البيانات.
متوسط/أقصى/أدنى لكل ساعة أو يوم.
خط صغير بجوار الرقم يبين الاتجاه.
تصميم لوحات المراقبة
25 دقيقة| المستخدم | السؤال الذي يأتي به | العنصر المناسب |
|---|---|---|
| مشرف المزرعة (هاتف) | هل أروي حقل B اليوم؟ | بطاقة كبيرة: رطوبة التربة + لون + توصية |
| الفني | هل كل العقد تعمل؟ | حالة Online/Offline + البطارية + آخر ظهور |
| الباحث | كيف تغيرت الرطوبة هذا الأسبوع؟ | رسم 7 أيام + جدول يومي + تصدير CSV |
| عميد الكلية (زيارة) | ما قيمة المشروع؟ | KPI: مياه موفرة، أيام بدون إجهاد مائي |
7 قواعد لا تكسرها
- الأهم أعلى اليسار/اليمين (حسب اتجاه القراءة) وأكبر حجماً.
- لون واحد = معنى واحد في كل الشاشة (أحمر = جاف أو عطل فقط).
- كل رقم بوحدته (% ، °C) ووقت آخر تحديث.
- القيمة المفقودة تظهر «—» وليس 0.
- لا تضع أكثر من 3 خطوط في رسم واحد.
- الرسوم الزمنية تبدأ من اليسار للقديم حتى في الواجهات العربية (عرف علمي).
- اللوحة تعمل على الهاتف أولاً.
نشاط 5 دقائق
ارسم على ورقة لوحة المزرعة كما تتخيلها (Wireframe): ماذا يظهر في أول شاشة بدون Scroll؟ ثم قارنها باللوحة الحالية.
Lab: لوحة Smart Farm من الداخل
35 دقيقةاللوحة صفحة واحدة dashboard.html وملف static/app.js. كل 15 ثانية تستدعي 3 Endpoints وتحدّث البطاقات والرسم والسجل؛ وكل 5 دقائق تطلب توقعات الذكاء الاصطناعي (السيشن 17).
// KFS Smart Farm dashboard — Fetch API + Chart.js, auto refresh every 15 s (sessions 9, 11, 12, 17)
const csrf = document.querySelector('meta[name="csrf-token"]').content;
const $ = (s) => document.querySelector(s);
const COLORS = ['#13787b', '#f5a524', '#1d2256', '#c0392b'];
let chart;
async function api(url, opts = {}) {
const res = await fetch(url, opts);
if (res.status === 401) { location.href = '/login'; return null; }
return res.json();
}
const fmt = (v, unit = '') => (v === null || v === undefined) ? '—' : `${v}${unit}`;
const ago = (ts) => {
if (!ts) return 'لا توجد قراءات';
const m = Math.round((Date.now() - new Date(ts)) / 60000);
return m < 1 ? 'الآن' : m < 60 ? `منذ ${m} دقيقة` : `منذ ${Math.round(m / 60)} ساعة`;
};
const soilClass = (v) => v == null ? '' : v < 30 ? 'dry' : v < 55 ? 'ok' : 'wet';
async function loadNodes() {
const data = await api('/api/v1/latest');
if (!data) return;
$('#nodes').innerHTML = data.nodes.map(n => `
<article class="card node ${n.online ? '' : 'offline'}">
<header><b>${n.name || n.node_id}</b><span class="badge ${n.online ? 'on' : 'off'}">${n.online ? 'متصل' : 'غير متصل'}</span></header>
<div class="soil ${soilClass(n.soil_moisture_pct)}"><strong>${fmt(n.soil_moisture_pct, '%')}</strong><span>رطوبة التربة</span>
<i style="--v:${n.soil_moisture_pct || 0}%"></i></div>
<dl>
<div><dt>الحرارة</dt><dd>${fmt(n.air_temp_c, ' °C')}</dd></div>
<div><dt>رطوبة الهواء</dt><dd>${fmt(n.air_humidity_pct, '%')}</dd></div>
<div><dt>الإضاءة</dt><dd>${fmt(n.light)}</dd></div>
<div><dt>البطارية</dt><dd>${fmt(n.battery_v, ' V')}</dd></div>
</dl>
<footer class="muted small">${n.node_id} · ${ago(n.ts)}</footer>
</article>`).join('');
}
async function loadChart() {
const metric = $('#metric').value, hours = $('#hours').value;
const data = await api(`/api/v1/readings?hours=${hours}`);
if (!data) return;
const byNode = {};
data.readings.forEach(r => { (byNode[r.node_id] ||= []).push({x: r.ts, y: r[metric]}); });
const datasets = Object.entries(byNode).map(([node, pts], i) => ({
label: node, data: pts, borderColor: COLORS[i % COLORS.length], backgroundColor: 'transparent',
pointRadius: 0, borderWidth: 2, tension: .3, spanGaps: false,
}));
if (!chart) {
chart = new Chart($('#chart'), {
type: 'line', data: {datasets},
options: {
animation: false, interaction: {mode: 'nearest', intersect: false},
scales: {x: {type: 'category', ticks: {maxTicksLimit: 8, callback(v) { return this.getLabelForValue(v).slice(5, 16).replace('T', ' '); }}}},
plugins: {legend: {position: 'bottom'}},
},
});
} else {
chart.data.datasets = datasets;
chart.update();
}
}
async function loadPredictions() {
const nodes = [...document.querySelectorAll('#control select[name=node_id] option')].map(o => o.value);
const out = await Promise.all(nodes.map(n => api(`/api/v1/predict/${n}`)));
$('#predictions').innerHTML = out.map((p, i) => !p || !p.ok
? `<div class="pred"><b>${nodes[i]}</b><span class="muted">لا توجد بيانات كافية</span></div>`
: `<div class="pred ${p.recommendation}">
<b>${p.node_id}</b>
<span class="rec">${p.recommendation === 'irrigate' ? '💧 يحتاج ري خلال 24 ساعة' : '✅ لا يحتاج ري الآن'}</span>
<span class="prob" title="احتمال الحاجة للري"><i style="--v:${Math.round(p.probability * 100)}%"></i>${Math.round(p.probability * 100)}%</span>
<small class="muted">${p.water_mm ? `كمية مقترحة ≈ ${p.water_mm} مم` : ''} ${p.note || ''} · ET0 غداً ${p.weather.et0_next_24h ?? '—'} مم · ${p.source}</small>
</div>`).join('');
}
async function loadDecisions() {
const data = await api('/api/v1/decisions');
if (!data) return;
$('#decisions').innerHTML = data.decisions.map(d =>
`<li class="${d.level}"><time>${d.ts.slice(5, 16).replace('T', ' ')}</time><b>${d.node_id}</b> ${d.message} <em>${d.source}</em></li>`
).join('') || '<li class="muted">لا توجد قرارات بعد</li>';
}
document.querySelectorAll('#control [data-action]').forEach(btn => btn.addEventListener('click', async () => {
const f = new FormData($('#control'));
const body = {node_id: f.get('node_id'), actuator: f.get('actuator'), action: btn.dataset.action,
duration_min: Number(f.get('duration_min')), confirm: false};
const label = `${body.action === 'on' ? 'تشغيل' : 'إيقاف'} ${body.actuator === 'pump' ? 'المضخة' : 'المروحة'} في ${body.node_id}`;
if (!confirm(`تأكيد: ${label}${body.action === 'on' ? ` لمدة ${body.duration_min} دقيقة` : ''}؟`)) return;
body.confirm = true;
const res = await api('/api/v1/commands', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf}, body: JSON.stringify(body)});
alert(res && res.ok ? `تم إرسال الأمر #${res.id} — سيُنفذ عند اتصال العقدة` : `خطأ: ${res && res.error}`);
loadDecisions();
}));
['#metric', '#hours'].forEach(s => $(s).addEventListener('change', loadChart));
function refresh() { loadNodes(); loadChart(); loadDecisions(); }
refresh(); loadPredictions();
setInterval(refresh, 15000);
setInterval(loadPredictions, 300000);
| الدالة | الـ Endpoint | ماذا ترسم |
|---|---|---|
loadNodes() | /api/v1/latest | بطاقة لكل عقدة: رطوبة تربة بلون الحالة + حرارة + رطوبة + بطارية + online |
loadChart() | /api/v1/readings?hours= | خط لكل عقدة للمتغير المختار (24 ساعة أو 7 أيام) |
loadDecisions() | /api/v1/decisions | سجل القرارات والتنبيهات |
loadPredictions() | /api/v1/predict/<node> | احتمال الحاجة للري |
شغّل اللوحة وجرّب عرض 7 أيام
شغّل
python app.pyثمpython simulate_nodes.py --backfill 168(أسبوع كامل من سجل المزرعة).سجل الدخول (admin / kfs-farm-2026) — غيّر كلمة السر فوراً من متغير البيئة
SF_ADMIN_PASSWORDقبل أي استخدام حقيقي.اختر «7 أيام» و«رطوبة التربة»: هل ترى نمط الجفاف اليومي؟ وأي حقل أكثر جفافاً؟
بدّل لـ «حرارة الهواء»: أين تظهر الصوبة؟ ولماذا هي أعلى؟
شغّل
python simulate_nodes.py(وضع حي) وراقب البطاقات تتحدث كل 15 ثانية.
Lab: تحديث لحظي عبر WebSocket
30 دقيقة| Polling (لوحتنا الآن) | MQTT over WebSocket | |
|---|---|---|
| زمن ظهور القراءة | حتى 15 ث | أقل من ثانية |
| الحمل على السيرفر | طلب كل 15 ث من كل متصفح | رسالة فقط عند وجود جديد |
| التعقيد | بسيط — fetch | يحتاج Broker بمنفذ WebSocket وصلاحيات |
| متى نستخدمه | لوحات عامة، بيانات كل دقيقة | مراقبة الري والمضخة لحظة بلحظة |
<div id="soil" style="font:700 3rem system-ui">—</div>
<div id="state"></div>
<script src="https://unpkg.com/mqtt@5.3.5/dist/mqtt.min.js"></script>
<script>
// Mosquitto must have: listener 9001 / protocol websockets (see broker/mosquitto.conf)
const client = mqtt.connect('ws://192.168.1.10:9001', { username: 'dashboard', password: '***' });
client.on('connect', () => client.subscribe('kfs/farm/+/telemetry'));
client.on('message', (topic, payload) => {
const node = topic.split('/')[2];
const d = JSON.parse(payload.toString());
if (node !== 'greenhouse_01' || d.soil_moisture_pct == null) return;
document.getElementById('soil').textContent = d.soil_moisture_pct.toFixed(0) + ' %';
document.getElementById('state').textContent = d.soil_moisture_pct < 30 ? 'جاف — يلزم الري' : 'مناسب';
});
client.on('error', e => console.error(e));
</script>
⚠️ مستخدم المتصفح يجب أن تكون صلاحياته قراءة فقط (ACL) — أي شخص يستطيع رؤية كلمة السر في كود الصفحة. الأوامر تبقى عبر Flask API بتسجيل دخول.
بديل جاهز: Grafana
Grafana أداة مجانية تبني لوحات احترافية بالسحب والإفلات، وتتصل بـ InfluxDB أو PostgreSQL، أو بـ SQLite عبر إضافة مجتمعية. ميزتها السرعة؛ عيبها أنها لا تفهم منطق المزرعة (أوامر المضخة، الذكاء الاصطناعي). لذلك نستخدم لوحتنا للتحكم، و Grafana اختيارياً للتحليل.
تحدي: Gauge وجدول يومي
25 دقيقةعداد (Gauge) لرطوبة التربة بـ Chart.js
أضف للوحة عداداً نصف دائري لرطوبة تربة عقدة مختارة: أحمر 0–30، أصفر 30–55، أخضر 55–100، ومؤشر للقيمة الحالية. تلميح: Chart.js doughnut بـ circumference: 180 و rotation: 270.
الحل (Solution)
const gauge = new Chart(document.getElementById('gauge'), {
type: 'doughnut',
data: { datasets: [{ data: [0, 100], backgroundColor: ['#13787b', '#e4e2da'], borderWidth: 0 }] },
options: { circumference: 180, rotation: 270, cutout: '75%', plugins: { tooltip: { enabled: false } } }
});
function setGauge(v) {
gauge.data.datasets[0].data = [v, 100 - v];
gauge.data.datasets[0].backgroundColor[0] = v < 30 ? '#c0392b' : v < 55 ? '#f5a524' : '#13787b';
gauge.update();
document.getElementById('gaugeValue').textContent = v.toFixed(0) + ' %';
}
// inside loadNodes(): setGauge(data.nodes.find(n => n.node_id === 'field_B').soil_moisture_pct ?? 0);
جدول المتوسطات اليومية آخر 7 أيام
استخدم /api/v1/stats/daily?days=7 لبناء جدول: اليوم | العقدة | متوسط الحرارة | أعلى حرارة | متوسط رطوبة التربة | أقل رطوبة تربة، مع تلوين خلية «أقل رطوبة» بالأحمر إذا كانت أقل من 30.
الحل (Solution)
async function loadDaily() {
const { days } = await api('/api/v1/stats/daily?days=7');
document.querySelector('#daily tbody').innerHTML = days.map(d => `
<tr><td>${d.day}</td><td>${d.node_id}</td><td>${d.temp_avg ?? '—'}</td><td>${d.temp_max ?? '—'}</td>
<td>${d.soil_avg ?? '—'}</td>
<td style="${d.soil_min < 30 ? 'color:#c0392b;font-weight:700' : ''}">${d.soil_min ?? '—'}</td></tr>`).join('');
}
ميداني: اللوحة مع المشرف
35 دقيقةلوحة مناخ المزرعة آخر 7 أيام + اختبار سهولة الاستخدام
غرفة المزرعة (شاشة/لابتوب) + هاتف المشرف
- العرض
افتح اللوحة على أكبر شاشة متاحة بالعقد الحقيقية (أو بيانات الأسبوع المحاكاة).
- اختبار المهام
اطلب من المشرف بدون مساعدة: (1) أي حقل أكثر جفافاً الآن؟ (2) هل كل العقد تعمل؟ (3) ما أعلى حرارة في الصوبة هذا الأسبوع؟ — سجل الزمن لكل مهمة.
- التعليقات
اسأله: ماذا ينقص؟ ماذا لا يفهمه؟ ماذا يريد أن يراه على الهاتف؟
- الأولويات
اختر أهم تحسينين لتنفيذهما قبل السيشن القادمة.
| المهمة | نجح؟ | الزمن (ث) | أين تردد/أخطأ | اقتراح المشرف |
|---|---|---|---|---|
- لا تعرض كلمة سر الدخول على الشاشة المشتركة.
اختبر فهمك
5 أسئلة · نتيجة فوريةالواجب والتسليم
الواجب الأسبوعي (Weekly Assignment)
نفذ التحديين (Gauge + الجدول اليومي) في نسخة فريقك من اللوحة.
نفذ أهم تحسينين من اختبار المشرف ووثق «قبل/بعد» بلقطات شاشة.
اختياري: شغّل live_gauge.html مع Mosquitto WebSocket وصوّر فيديو للتحديث اللحظي.
قائمة التسليم (Submission Checklist)
مراجع ومصادر
استعد للسيشن القادمة
السيشن القادمة: التحكم في المضخة من المتصفح بأمان. كل فريق يحضر موديول Relay ومضخة 5V صغيرة وخرطوم وكوب ماء.