Lab Server adalah satu program Python yang sudah berisi broker MQTT, server HTTP, halaman web, dan seluruh alat pemantauan. Tidak perlu memasang Mosquitto, Node-RED, maupun Docker.
| Bagian | Port | Dipakai oleh |
|---|---|---|
| Broker MQTT bawaan | 1883 | Kit (mode MQTT), MQTT Explorer peserta, jembatan cloud |
| Server HTTP + halaman web | 8080 | Kit (mode HTTP & pengaturan), Halaman Instance, Halaman Instruktur |
| Server uji koneksi | UDP 5005, TCP 5006 | Tombol "Uji Koneksi" di halaman kit |
| Alamat | Untuk |
|---|---|
http://IP-SERVER:8080/ | Peserta memilih instance |
http://IP-SERVER:8080/i/peserta-03 | Halaman Instance peserta-03 (perlu password instance) |
http://IP-SERVER:8080/admin | Halaman Instruktur (password bawaan admin123) |
Instance bawaan: peserta-01 sampai peserta-10 dengan password iot01 sampai iot10. Nama peserta dan password bisa diubah di Halaman Instruktur; instance tambahan bisa dibuat dengan tombol Tambah Instance. Semua tersimpan di server-lab\labserver\data\lab.json.
Unduh Python 3 dari python.org/downloads. Saat memasang, centang "Add python.exe to PATH" di layar pertama, lalu klik Install Now.
Ekstrak paket ke, misalnya, C:\server-lab. Isinya: MULAI-SERVER.bat, IZINKAN-FIREWALL.bat, KIT-SIMULASI.bat, dan folder labserver.
Klik kanan IZINKAN-FIREWALL.bat → Run as administrator. Port 1883, 8080, 5005, 5006 akan diizinkan masuk.
Di halaman admin router, buat DHCP reservation untuk laptop server (misalnya 192.168.1.10). Atur WiFi lab: 2,4 GHz, WPA2, channel tetap. Matikan mode sleep laptop selama pelatihan (Settings → Power).
Dobel-klik MULAI-SERVER.bat. Pertama kali, skrip memasang paket paho-mqtt (butuh internet, untuk jembatan cloud dan kit simulasi). Jendela hitam menampilkan alamat IP server. Biarkan jendela ini terbuka selama pelatihan.
Dobel-klik KIT-SIMULASI.bat, isi peserta-01, iot01, 3. Buka http://localhost:8080/i/peserta-01: tiga kit simulasi (kit-51..53) muncul di Dashboard.
Buka http://localhost:8080/admin, masuk dengan admin123, lalu ganti di bagian Keamanan.
Daftar di console.hivemq.cloud, buat cluster Serverless (Free). Catat Cluster URL (misalnya xxxx.s1.eu.hivemq.cloud). Di menu Access Management buat dua akun: bridge-lab (untuk Lab Server) dan pemantau (untuk MQTT Explorer / HP pimpinan).
Di http://localhost:8080/admin bagian Jembatan ke Cloud: Aktif = Ya, alamat = Cluster URL, Enkripsi TLS = Ya, port 8883, username/password akun bridge-lab, lalu Simpan & Sambungkan. Status harus menjadi tersambung. Setelah dicek, boleh dinonaktifkan lagi sampai sesi Modul 6.
Cek dari laptop lain: MQTT Explorer → host = Cluster URL, port 8883, Encryption (tls) aktif, akun pemantau. Pohon topik lab/ muncul.
Board Manager URL: https://espressif.github.io/arduino-esp32/package_esp32_index.json. Pasang board esp32 by Espressif Systems, lalu library PubSubClient (Nick O'Leary), DHT sensor library dan Adafruit Unified Sensor.
Buka firmware\kit_iot\kit_iot.ino, pilih board ESP32 Dev Module, lalu Upload ke setiap kit (±1 menit per kit). Peserta mulai dari langkah "Sambungkan kit lewat HP" di Modul 2.
Klik Sketch → Export Compiled Binary, salin kit_iot.ino.merged.bin dari folder build ke folder firmware/ situs modul (di samping manifest.json). Situs modul harus dibuka lewat https, misalnya dari VPS.
/*
* ================================================================
* KIT IoT PELATIHAN - firmware tunggal v3 (MQTT + HTTP)
* BEST Lab - Politeknik Negeri Semarang
* ================================================================
* Peserta TIDAK perlu mengubah kode ini. Semua pengaturan lewat HP/laptop:
* 1. Nyalakan kit -> muncul WiFi "KIT-IOT-xxxx" (password: kitiot123)
* 2. Sambungkan HP ke WiFi tersebut -> halaman pengaturan terbuka otomatis
* (jika tidak, buka http://192.168.4.1)
* 3. Isi WiFi lab, IP Lab Server, nama & password instance, ID kit -> Simpan
* 4. Buka http://<IP kit>/ untuk melihat data, kendali LED, uji koneksi
*
* Protokol (MQTT atau HTTP) TIDAK diatur di kit, melainkan di halaman instance
* pada Lab Server. Kit membaca pengaturan itu setiap 30 detik lalu menyesuaikan.
*
* Reset pengaturan: tahan tombol BOOT 5 detik saat kit menyala.
* Komponen: DHT11 (DATA) -> GPIO4 | LED (+ resistor 220 ohm ke GND) -> GPIO26
* Tombol BOOT (GPIO0) | LED bawaan (GPIO2) ikut menyala bersama LED
* Modul DHT11 3 kaki: VCC -> 3V3, DATA -> GPIO4, GND -> GND (resistor pull-up sudah ada di modul)
* Library: PubSubClient (Nick O'Leary), DHT sensor library (Adafruit), Adafruit Unified Sensor
* Board : "ESP32 Dev Module"
* ================================================================
*/
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <PubSubClient.h>
#include <DHT.h>
#define FW_VERSION "3.1.0"
// ---------------- PIN ----------------
#define PIN_DHT 4
#define PIN_LED 26 // LED yang dikendalikan dari aplikasi
#define PIN_BTN 0 // tombol BOOT
#define PIN_ONBOARD 2 // LED bawaan papan
#define DHTTYPE DHT11
const char* AP_PASS = "kitiot123";
const uint16_t MQTT_PORT = 1883;
const uint16_t HTTP_PORT = 8080;
const uint16_t ECHO_PORT = 5005;
const uint16_t TP_PORT = 5006;
// ---------------- PENGATURAN (disimpan di flash) ----------------
struct Config { String ssid, pass, server, inst, ipass, kitId; } cfg;
Preferences prefs;
WebServer web(80);
DNSServer dns;
WiFiClient net; // untuk MQTT
WiFiClient httpNet; // untuk HTTP
PubSubClient mqtt(net);
DHT dht(PIN_DHT, DHTTYPE);
bool portalOnly = false, apActive = false;
String apName;
char clientId[72];
char tStatus[96], tTele[96], tHealth[96], tSet[96], tState[96], tTest[96];
// pengaturan dari Lab Server
String proto = "mqtt";
uint32_t intervalMs = 5000, pollMs = 2000;
// ---------------- DATA & METRIK ----------------
float tempC = NAN, humid = NAN;
bool ledOn = false;
uint32_t seq = 0, dhtOk = 0, dhtFail = 0, pubOk = 0, pubFail = 0;
uint32_t mqttReconnects = 0, wifiDrops = 0, cmdRx = 0, loopMaxUs = 0;
unsigned long upMs = 0, lastAcc = 0, wifiLostAt = 0, lastHttpOk = 0;
unsigned long tSensor = 0, tTele0 = 0, tHealth0 = 0, tMqttTry = 0, tLog = 0, tCfg = 0, tPoll = 0, btnDownAt = 0;
bool wasConnected = false, cfgLoaded = false;
struct Sample { uint32_t up; int16_t rssi; uint32_t heap; float t, h; uint8_t led; float dhtRate; uint32_t pubFail, drops; char p; };
const int LOG_N = 120;
Sample logBuf[LOG_N];
int logHead = 0, logCount = 0;
// ================================================================
// UTIL
// ================================================================
void setLed(bool on) {
ledOn = on;
digitalWrite(PIN_LED, on ? HIGH : LOW);
digitalWrite(PIN_ONBOARD, on ? HIGH : LOW);
}
String num(float v, int d = 1) { return isnan(v) ? String("null") : String(v, d); }
float dhtRate() { uint32_t n = dhtOk + dhtFail; return n ? 100.0f * dhtOk / n : 0; }
float availPct() { unsigned long up = millis(); return up ? 100.0f * upMs / up : 0; }
bool serverOk() { return proto == "mqtt" ? mqtt.connected() : (lastHttpOk && millis() - lastHttpOk < 2 * intervalMs + 3000); }
String htmlEscape(const String& s) {
String o; o.reserve(s.length());
for (char c : s) {
if (c == '<') o += "<"; else if (c == '>') o += ">";
else if (c == '"') o += """; else if (c == '&') o += "&"; else o += c;
}
return o;
}
// ambil nilai teks / angka dari JSON sederhana, contoh: "protocol": "http"
String jsonStr(const String& js, const char* key) {
int k = js.indexOf(String("\"") + key + "\"");
if (k < 0) return "";
int c = js.indexOf(':', k); if (c < 0) return "";
int q1 = js.indexOf('"', c + 1); if (q1 < 0) return "";
int q2 = js.indexOf('"', q1 + 1); if (q2 < 0) return "";
return js.substring(q1 + 1, q2);
}
long jsonNum(const String& js, const char* key, long def) {
int k = js.indexOf(String("\"") + key + "\"");
if (k < 0) return def;
int c = js.indexOf(':', k); if (c < 0) return def;
return atol(js.c_str() + c + 1);
}
// ================================================================
// PENYIMPANAN PENGATURAN
// ================================================================
void loadConfig() {
prefs.begin("kit", true);
cfg.ssid = prefs.getString("ssid", "");
cfg.pass = prefs.getString("pass", "");
cfg.server = prefs.getString("server", "");
cfg.inst = prefs.getString("inst", "");
cfg.ipass = prefs.getString("ipass", "");
cfg.kitId = prefs.getString("kit", "");
prefs.end();
if (cfg.kitId == "") cfg.kitId = "kit-01";
}
void saveConfig() {
prefs.begin("kit", false);
prefs.putString("ssid", cfg.ssid); prefs.putString("pass", cfg.pass);
prefs.putString("server", cfg.server); prefs.putString("inst", cfg.inst);
prefs.putString("ipass", cfg.ipass); prefs.putString("kit", cfg.kitId);
prefs.end();
}
void factoryReset() {
prefs.begin("kit", false); prefs.clear(); prefs.end();
delay(300); ESP.restart();
}
// ================================================================
// HTTP KE LAB SERVER
// ================================================================
int httpReq(bool post, const String& path, const String& body, String* resp) {
if (cfg.server == "" || WiFi.status() != WL_CONNECTED) return -1;
HTTPClient http;
http.setTimeout(3000);
String url = "http://" + cfg.server + ":" + String(HTTP_PORT) + "/api/i/" + cfg.inst + path +
(path.indexOf('?') >= 0 ? "&" : "?") + "key=" + cfg.ipass;
if (!http.begin(httpNet, url)) return -1;
int code;
if (post) { http.addHeader("Content-Type", "application/json"); code = http.POST(body); }
else code = http.GET();
if (resp && code > 0) *resp = http.getString();
http.end();
if (code >= 200 && code < 300) lastHttpOk = millis();
return code;
}
void fetchConfig() {
String r;
int code = httpReq(false, "/config?kit=" + cfg.kitId, "", &r);
if (code != 200) return;
String p = jsonStr(r, "protocol");
long iv = jsonNum(r, "interval_s", 5), pl = jsonNum(r, "poll_s", 2);
if (iv >= 1) intervalMs = iv * 1000UL;
if (pl >= 1) pollMs = pl * 1000UL;
if ((p == "mqtt" || p == "http") && p != proto) {
if (proto == "mqtt" && mqtt.connected()) { mqtt.publish(tStatus, "offline", true); mqtt.disconnect(); }
proto = p;
Serial.printf("Protokol diganti oleh server: %s\n", proto.c_str());
}
cfgLoaded = true;
}
// kirim satu jenis data lewat protokol yang aktif
bool sendKind(const char* kind, const String& body, bool retained = false) {
bool ok;
if (proto == "mqtt") {
char topic[96]; snprintf(topic, sizeof(topic), "lab/%s/%s/%s", cfg.inst.c_str(), cfg.kitId.c_str(), kind);
ok = mqtt.connected() && mqtt.publish(topic, body.c_str(), retained);
} else {
String k = String(kind); if (k == "led/state") k = "ack";
int code = httpReq(true, "/k/" + cfg.kitId + "/" + k, body, nullptr);
ok = code >= 200 && code < 300;
}
if (ok) pubOk++; else pubFail++;
return ok;
}
void publishState(long id, const char* src) {
char b[80]; snprintf(b, sizeof(b), "{\"v\":%d,\"id\":%ld,\"src\":\"%s\"}", ledOn ? 1 : 0, id, src);
sendKind("led/state", String(b), true);
}
// perintah: "1"/"0"/"ON"/"OFF" atau {"v":1,"id":123}
void applyCommand(const char* msg) {
cmdRx++;
int v = -1; long id = 0;
const char* pv = strstr(msg, "\"v\"");
if (pv) {
pv = strchr(pv, ':'); if (pv) v = atoi(pv + 1);
const char* pid = strstr(msg, "\"id\""); if (pid && (pid = strchr(pid, ':'))) id = atol(pid + 1);
} else if (!strcmp(msg, "1") || !strcasecmp(msg, "ON")) v = 1;
else if (!strcmp(msg, "0") || !strcasecmp(msg, "OFF")) v = 0;
if (v == 0 || v == 1) { setLed(v == 1); publishState(id, proto.c_str()); }
}
void pollCommand() {
String r;
int code = httpReq(false, "/k/" + cfg.kitId + "/command", "", &r);
if (code == 200 && r.length()) applyCommand(r.c_str());
}
// ================================================================
// MQTT
// ================================================================
void onMessage(char* topic, byte* payload, unsigned int len) {
if (strcmp(topic, tSet) != 0) return;
char msg[96]; unsigned int n = len < sizeof(msg) - 1 ? len : sizeof(msg) - 1;
memcpy(msg, payload, n); msg[n] = '\0';
applyCommand(msg);
}
void connectMqtt() {
if (cfg.server == "") return;
if (mqtt.connect(clientId, cfg.inst.c_str(), cfg.ipass.c_str(), tStatus, 1, true, "offline")) {
mqtt.publish(tStatus, "online", true);
mqtt.subscribe(tSet, 1);
publishState(0, "boot");
}
}
// ================================================================
// HALAMAN WEB KIT
// ================================================================
const char CSS[] PROGMEM = R"CSS(
<style>
*{box-sizing:border-box}body{font-family:Segoe UI,Arial,sans-serif;margin:0;background:#f4f7f6;color:#222;font-size:18px}
header{background:#004280;color:#fff;padding:14px 18px;font-weight:700;font-size:20px}
header small{display:block;font-weight:400;font-size:14px;opacity:.85}
main{max-width:640px;margin:auto;padding:16px}
.card{background:#fff;border-radius:10px;padding:16px;margin-bottom:14px;box-shadow:0 2px 6px rgba(0,0,0,.08)}
.card h2{margin:0 0 10px;font-size:18px;color:#004280;border-left:5px solid #F39800;padding-left:10px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.v{background:#f4f7f6;border-radius:8px;padding:10px}.v b{display:block;font-size:26px;color:#004280}.v span{font-size:14px;color:#666}
.btn{display:block;width:100%;padding:16px;font-size:20px;font-weight:700;border:0;border-radius:8px;margin-top:8px;cursor:pointer;color:#fff;background:#004280;text-align:center;text-decoration:none}
.on{background:#2e7d32}.off{background:#c62828}.alt{background:#fff;color:#004280;border:2px solid #004280}
label{display:block;font-weight:600;margin-top:12px}input,select{width:100%;padding:12px;font-size:18px;border:1px solid #bbb;border-radius:6px;margin-top:4px}
.ok{color:#2e7d32;font-weight:700}.bad{color:#c62828;font-weight:700}.hint{font-size:14px;color:#666}
table{width:100%;border-collapse:collapse;font-size:16px}td{padding:6px;border-bottom:1px solid #eee}td:last-child{text-align:right;font-weight:600}
</style>)CSS";
String pageHead(const String& title) {
String h = F("<!doctype html><html lang='id'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>");
h += title; h += F("</title>"); h += FPSTR(CSS);
h += F("</head><body><header>"); h += title; h += F("<small>");
h += htmlEscape(cfg.kitId + " @ " + cfg.inst); h += F(" · firmware " FW_VERSION "</small></header><main>");
return h;
}
void sendSetup() {
int n = WiFi.scanNetworks();
String h = pageHead("Pengaturan Kit");
h += F("<form method='post' action='/save' class='card'><h2>1. WiFi Lab</h2><label>Nama WiFi</label><select name='ssid'>");
for (int i = 0; i < n; i++) {
String s = WiFi.SSID(i);
h += "<option" + String(s == cfg.ssid ? " selected" : "") + ">" + htmlEscape(s) + "</option>";
}
h += F("</select><p class='hint'>Tidak muncul? Ketik manual di bawah.</p><input name='ssid2' placeholder='(opsional) nama WiFi manual'>");
h += F("<label>Password WiFi</label><input name='pass' type='password' value='"); h += htmlEscape(cfg.pass); h += F("'>");
h += F("<h2 style='margin-top:20px'>2. Lab Server</h2><label>Alamat IP Lab Server</label><input name='server' placeholder='contoh 192.168.1.10' value='");
h += htmlEscape(cfg.server);
h += F("'><label>Nama instance</label><input name='inst' placeholder='contoh peserta-03' value='"); h += htmlEscape(cfg.inst);
h += F("'><label>Password instance</label><input name='ipass' type='password' value='"); h += htmlEscape(cfg.ipass);
h += F("'><h2 style='margin-top:20px'>3. Identitas Kit</h2><label>ID Kit</label><input name='kit' value='"); h += htmlEscape(cfg.kitId);
h += F("'><p class='hint'>Contoh kit-01. Huruf kecil, tanpa spasi. Protokol (MQTT/HTTP) diatur dari halaman instance di Lab Server.</p>");
h += F("<button class='btn' type='submit'>Simpan & Mulai Ulang</button></form>");
if (!portalOnly) h += F("<a class='btn alt' href='/'>Kembali</a>");
h += F("</main></body></html>");
web.send(200, "text/html", h);
}
String clean(String s) { s.trim(); s.toLowerCase(); s.replace(" ", "-"); return s; }
void handleSave() {
String s = web.arg("ssid2"); s.trim();
cfg.ssid = s.length() ? s : web.arg("ssid");
cfg.pass = web.arg("pass");
cfg.server = web.arg("server"); cfg.server.trim();
cfg.inst = clean(web.arg("inst"));
cfg.ipass = web.arg("ipass");
String k = clean(web.arg("kit")); if (k.length()) cfg.kitId = k;
saveConfig();
String h = pageHead("Tersimpan");
h += F("<div class='card'><h2>Pengaturan tersimpan</h2><p>Kit akan mulai ulang dan tersambung ke WiFi <b>");
h += htmlEscape(cfg.ssid);
h += F("</b>.</p><p>Sambungkan kembali HP ke WiFi lab, lalu lihat kit Anda di halaman instance (menu Perangkat).</p></div></main></body></html>");
web.send(200, "text/html", h);
delay(1500);
ESP.restart();
}
const char HOME_JS[] PROGMEM = R"JS(
<script>
function g(id){return document.getElementById(id)}
async function upd(){try{const d=await (await fetch('/api/status')).json();
g('t').textContent=d.temp_c===null?'-':d.temp_c+' °C';g('h').textContent=d.hum_pct===null?'-':d.hum_pct+' %';
g('r').textContent=d.led?'MENYALA':'MATI';g('r').className=d.led?'ok':'bad';
g('pr').textContent=d.protokol.toUpperCase();g('rs').textContent=d.rssi+' dBm ('+d.rssi_ket+')';
g('mq').textContent=d.server?'Tersambung':'Terputus';g('mq').className=d.server?'ok':'bad';
g('up').textContent=Math.floor(d.uptime_s/60)+' menit';g('dr').textContent=d.dht_rate_pct+' %';g('av').textContent=d.avail_pct+' %';
g('hp').textContent=Math.round(d.heap/1024)+' KB';g('pf').textContent=d.pub_fail;g('wd').textContent=d.wifi_drop;}catch(e){}}
async function led(v){await fetch('/api/led?on='+v);upd()}
async function uji(){const b=g('bt');b.textContent='Sedang menguji... (±15 detik)';b.disabled=true;
try{const d=await (await fetch('/api/test')).json();
g('hasil').innerHTML='<table><tr><td>Kekuatan sinyal (rata-rata)</td><td>'+d.rssi_avg+' dBm</td></tr><tr><td>Kualitas sinyal</td><td>'+d.quality_pct+' %</td></tr><tr><td>Waktu tanggap (ping)</td><td>'+d.rtt_avg_ms+' ms</td></tr><tr><td>Variasi waktu (jitter)</td><td>'+d.jitter_ms+' ms</td></tr><tr><td>Paket hilang</td><td>'+d.loss_pct+' %</td></tr><tr><td>Kecepatan unduh</td><td>'+d.dl_kbps+' kbit/s</td></tr><tr><td>Kecepatan unggah</td><td>'+d.ul_kbps+' kbit/s</td></tr></table><p class=hint>Hasil juga terkirim ke halaman instance (menu Uji Koneksi).</p>';}
catch(e){g('hasil').textContent='Uji gagal. Pastikan Lab Server menyala.'}b.textContent='Uji Koneksi';b.disabled=false}
upd();setInterval(upd,2000);
</script>)JS";
void sendHome() {
String h = pageHead("Kit IoT");
h += F("<div class='card'><h2>Data Sensor</h2><div class='grid'>"
"<div class='v'><span>Suhu</span><b id='t'>-</b></div><div class='v'><span>Kelembapan</span><b id='h'>-</b></div>"
"<div class='v' style='grid-column:1/3'><span>LED</span><b id='r'>-</b></div></div>"
"<button class='btn on' onclick='led(1)'>NYALAKAN LED</button><button class='btn off' onclick='led(0)'>MATIKAN LED</button></div>");
h += F("<div class='card'><h2>Kesehatan Perangkat</h2><table>"
"<tr><td>Protokol aktif</td><td id='pr'>-</td></tr>"
"<tr><td>Sinyal WiFi</td><td id='rs'>-</td></tr><tr><td>Koneksi ke Lab Server</td><td id='mq'>-</td></tr>"
"<tr><td>Lama menyala</td><td id='up'>-</td></tr><tr><td>Keberhasilan baca sensor</td><td id='dr'>-</td></tr>"
"<tr><td>Ketersediaan koneksi</td><td id='av'>-</td></tr><tr><td>Memori bebas</td><td id='hp'>-</td></tr>"
"<tr><td>Kirim data gagal</td><td id='pf'>-</td></tr><tr><td>WiFi sempat putus</td><td id='wd'>-</td></tr></table>"
"<a class='btn alt' href='/log.csv'>Unduh Catatan 30 Menit (CSV)</a></div>");
h += F("<div class='card'><h2>Uji Koneksi</h2><button class='btn' id='bt' onclick='uji()'>Uji Koneksi</button><div id='hasil'></div></div>"
"<a class='btn alt' href='/setup'>Pengaturan</a>");
h += FPSTR(HOME_JS);
h += F("</main></body></html>");
web.send(200, "text/html", h);
}
const char* rssiKet(int r) {
if (r == 0) return "terputus";
if (r >= -50) return "sangat baik";
if (r >= -60) return "baik";
if (r >= -70) return "cukup";
return "lemah";
}
String statusJson() {
int r = WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0;
String j = "{\"kit\":\"" + cfg.kitId + "\",\"instance\":\"" + cfg.inst + "\",\"fw\":\"" FW_VERSION "\",\"ip\":\"" + WiFi.localIP().toString() + "\"";
j += ",\"protokol\":\"" + proto + "\",\"uptime_s\":" + String(millis() / 1000UL);
j += ",\"temp_c\":" + num(tempC) + ",\"hum_pct\":" + num(humid);
j += ",\"led\":" + String(ledOn ? 1 : 0);
j += ",\"rssi\":" + String(r) + ",\"rssi_ket\":\"" + rssiKet(r) + "\"";
j += ",\"server\":" + String(serverOk() ? "true" : "false");
j += ",\"heap\":" + String(ESP.getFreeHeap()) + ",\"min_heap\":" + String(ESP.getMinFreeHeap());
j += ",\"dht_rate_pct\":" + String(dhtRate(), 1) + ",\"pub_ok\":" + String(pubOk) + ",\"pub_fail\":" + String(pubFail);
j += ",\"mqtt_reconnect\":" + String(mqttReconnects) + ",\"wifi_drop\":" + String(wifiDrops);
j += ",\"cmd_rx\":" + String(cmdRx) + ",\"loop_max_us\":" + String(loopMaxUs);
j += ",\"avail_pct\":" + String(availPct(), 2) + ",\"mqtt_avail_pct\":" + String(availPct(), 2) + "}";
return j;
}
void sendLogCsv() {
String c = "uptime_s,protokol,rssi_dbm,heap,temp_c,hum_pct,led,dht_rate_pct,pub_fail,wifi_drop\n";
int start = (logHead - logCount + LOG_N) % LOG_N;
for (int i = 0; i < logCount; i++) {
Sample& s = logBuf[(start + i) % LOG_N];
c += String(s.up) + "," + String(s.p == 'h' ? "http" : "mqtt") + "," + String(s.rssi) + "," + String(s.heap) + "," +
num(s.t) + "," + num(s.h) + "," + String(s.led) + "," + String(s.dhtRate, 1) + "," + String(s.pubFail) + "," + String(s.drops) + "\n";
}
web.sendHeader("Content-Disposition", "attachment; filename=\"" + cfg.kitId + "_catatan.csv\"");
web.send(200, "text/csv", c);
}
// ================================================================
// UJI KONEKSI (measurement test) ke Lab Server
// ================================================================
void keepAlive() { if (proto == "mqtt" && mqtt.connected()) mqtt.loop(); } // jangan panggil web.handleClient di sini
String runTest() {
long rs = 0; int rmin = 0, rmax = -200;
for (int i = 0; i < 20; i++) {
int r = WiFi.RSSI(); rs += r;
if (i == 0 || r < rmin) rmin = r;
if (r > rmax) rmax = r;
delay(100);
}
float ravg = rs / 20.0f;
int q = (int)(2 * (ravg + 100)); q = q < 0 ? 0 : (q > 100 ? 100 : q);
WiFiUDP udp; udp.begin(4210);
const int N = 50; int got = 0; float sum = 0, prev = -1, jsum = 0, mn = 1e9, mx = 0; int jn = 0;
uint8_t tx[32], rx[64];
for (uint32_t s = 1; s <= (uint32_t)N; s++) {
memset(tx, 0, sizeof(tx)); memcpy(tx, &s, 4);
uint32_t t0 = micros();
udp.beginPacket(cfg.server.c_str(), ECHO_PORT); udp.write(tx, sizeof(tx)); udp.endPacket();
bool ok = false;
while (micros() - t0 < 500000UL) {
int n = udp.parsePacket();
if (n >= 4) { udp.read(rx, sizeof(rx)); uint32_t rsq; memcpy(&rsq, rx, 4); if (rsq == s) { ok = true; break; } }
delay(0);
}
if (ok) {
float rtt = (micros() - t0) / 1000.0f; got++; sum += rtt;
if (rtt < mn) mn = rtt;
if (rtt > mx) mx = rtt;
if (prev >= 0) { jsum += fabsf(rtt - prev); jn++; }
prev = rtt;
}
keepAlive(); delay(40);
}
udp.stop();
float loss = 100.0f * (N - got) / N, ravgRtt = got ? sum / got : 0, jit = jn ? jsum / jn : 0;
const uint32_t BYTES = 131072;
float dl = 0, ul = 0;
WiFiClient c;
if (c.connect(cfg.server.c_str(), TP_PORT)) {
c.printf("DL %lu\n", (unsigned long)BYTES);
uint8_t buf[1024]; uint32_t n = 0, t0 = millis();
while (n < BYTES && millis() - t0 < 15000) {
int a = c.available();
if (a > 0) { int r = c.read(buf, a > (int)sizeof(buf) ? sizeof(buf) : a); if (r > 0) n += r; }
else if (!c.connected()) break; else delay(1);
}
uint32_t ms = millis() - t0; c.stop();
dl = ms ? n * 8.0f / ms : 0;
}
keepAlive();
if (c.connect(cfg.server.c_str(), TP_PORT)) {
c.printf("UL %lu\n", (unsigned long)BYTES);
static uint8_t buf[1024]; memset(buf, 0xA5, sizeof(buf));
uint32_t sent = 0, t0 = millis();
while (sent < BYTES && millis() - t0 < 15000) {
size_t w = c.write(buf, BYTES - sent > sizeof(buf) ? sizeof(buf) : BYTES - sent);
if (w == 0) { if (!c.connected()) break; delay(1); }
sent += w;
}
uint32_t tw = millis();
while (millis() - tw < 8000) {
if (c.available()) { if (c.read() == '\n') break; }
else if (!c.connected()) break; else delay(1);
}
uint32_t ms = millis() - t0; c.stop();
ul = ms ? sent * 8.0f / ms : 0;
}
String j = "{\"kit\":\"" + cfg.kitId + "\",\"rssi_avg\":" + String(ravg, 1) + ",\"rssi_min\":" + String(rmin) +
",\"rssi_max\":" + String(rmax) + ",\"quality_pct\":" + String(q) +
",\"rtt_avg_ms\":" + String(ravgRtt, 1) + ",\"rtt_min_ms\":" + String(got ? mn : 0, 1) +
",\"rtt_max_ms\":" + String(mx, 1) + ",\"jitter_ms\":" + String(jit, 2) + ",\"loss_pct\":" + String(loss, 1) +
",\"dl_kbps\":" + String(dl, 0) + ",\"ul_kbps\":" + String(ul, 0) + "}";
sendKind("test", j);
return j;
}
// ================================================================
// MODE HOTSPOT (portal pengaturan)
// ================================================================
void startAP() {
if (apActive) return;
WiFi.mode(portalOnly ? WIFI_AP : WIFI_AP_STA);
WiFi.softAP(apName.c_str(), AP_PASS);
dns.start(53, "*", WiFi.softAPIP());
apActive = true;
}
void stopAP() {
if (!apActive || portalOnly) return;
dns.stop();
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
apActive = false;
}
void setupRoutes() {
web.on("/", []() { if (portalOnly) sendSetup(); else sendHome(); });
web.on("/setup", sendSetup);
web.on("/save", HTTP_POST, handleSave);
web.on("/api/status", []() { web.send(200, "application/json", statusJson()); });
web.on("/api/led", []() {
if (web.hasArg("on")) { setLed(web.arg("on") == "1"); publishState(0, "web"); }
web.send(200, "application/json", statusJson());
});
web.on("/api/test", []() {
if (WiFi.status() != WL_CONNECTED || cfg.server == "") { web.send(503, "application/json", "{\"error\":\"belum tersambung\"}"); return; }
web.send(200, "application/json", runTest());
});
web.on("/log.csv", sendLogCsv);
web.onNotFound([]() {
if (apActive) {
web.sendHeader("Location", String("http://") + WiFi.softAPIP().toString() + "/");
web.send(302, "text/plain", "");
} else {
web.send(404, "text/plain", "Halaman tidak ada");
}
});
}
void setup() {
Serial.begin(115200);
pinMode(PIN_LED, OUTPUT); pinMode(PIN_ONBOARD, OUTPUT); pinMode(PIN_BTN, INPUT_PULLUP);
setLed(false);
dht.begin();
loadConfig();
uint64_t mac = ESP.getEfuseMac();
char ap[24]; snprintf(ap, sizeof(ap), "KIT-IOT-%04X", (unsigned)(mac >> 32) & 0xFFFF);
apName = ap;
const char* i = cfg.inst.c_str(); const char* k = cfg.kitId.c_str();
snprintf(clientId, sizeof(clientId), "%s@%s", k, i); // unik antar instance
snprintf(tStatus, sizeof(tStatus), "lab/%s/%s/status", i, k);
snprintf(tTele, sizeof(tTele), "lab/%s/%s/telemetry", i, k);
snprintf(tHealth, sizeof(tHealth), "lab/%s/%s/health", i, k);
snprintf(tSet, sizeof(tSet), "lab/%s/%s/led/set", i, k);
snprintf(tState, sizeof(tState), "lab/%s/%s/led/state", i, k);
snprintf(tTest, sizeof(tTest), "lab/%s/%s/test", i, k);
portalOnly = (cfg.ssid == "");
if (portalOnly) {
startAP();
Serial.printf("Mode pengaturan: sambungkan ke WiFi %s (password %s), buka http://192.168.4.1\n", ap, AP_PASS);
} else {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(cfg.ssid.c_str(), cfg.pass.c_str());
mqtt.setServer(cfg.server.c_str(), MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(768);
mqtt.setKeepAlive(15);
}
setupRoutes();
web.begin();
lastAcc = millis();
}
void loop() {
uint32_t t0 = micros();
unsigned long now = millis();
if (apActive) dns.processNextRequest();
web.handleClient();
// tombol BOOT: tekan singkat = nyala/matikan LED, tahan 5 s = reset pengaturan
bool down = digitalRead(PIN_BTN) == LOW;
if (down && btnDownAt == 0) btnDownAt = now;
if (!down && btnDownAt) {
unsigned long held = now - btnDownAt; btnDownAt = 0;
if (held > 50 && held < 2000) { setLed(!ledOn); publishState(0, "button"); }
}
if (down && btnDownAt && now - btnDownAt > 5000) factoryReset();
if (now - tSensor >= 2000) {
tSensor = now;
float t = dht.readTemperature(), h = dht.readHumidity();
if (isnan(t) || isnan(h)) dhtFail++; else { tempC = t; humid = h; dhtOk++; }
}
if (!portalOnly) {
bool wifiOk = WiFi.status() == WL_CONNECTED;
if (wifiOk) {
if (!wasConnected) {
wasConnected = true; wifiLostAt = 0; stopAP();
Serial.printf("WiFi OK, IP kit: %s\n", WiFi.localIP().toString().c_str());
fetchConfig(); tCfg = now;
}
if (now - tCfg >= 30000) { tCfg = now; fetchConfig(); } // ikuti pengaturan instance
if (proto == "mqtt") {
if (!mqtt.connected() && now - tMqttTry > 5000) { tMqttTry = now; mqttReconnects++; connectMqtt(); }
if (mqtt.connected()) mqtt.loop();
} else {
if (mqtt.connected()) mqtt.disconnect();
if (now - tPoll >= pollMs) { tPoll = now; pollCommand(); } // HTTP: tanya perintah
}
} else {
if (wasConnected) { wasConnected = false; wifiDrops++; }
if (wifiLostAt == 0) wifiLostAt = now;
if (now - wifiLostAt > 30000) startAP();
}
if (serverOk()) upMs += now - lastAcc;
lastAcc = now;
// seq tetap naik walau putus: platform dapat menghitung data hilang
if (now - tTele0 >= intervalMs) {
tTele0 = now; seq++;
String j = "{\"seq\":" + String(seq) + ",\"temp_c\":" + num(tempC) + ",\"hum_pct\":" + num(humid) +
",\"led\":" + String(ledOn ? 1 : 0) + ",\"rssi\":" + String(WiFi.RSSI()) + "}";
if (wifiOk) sendKind("telemetry", j); else pubFail++;
}
if (wifiOk && now - tHealth0 >= 30000) { tHealth0 = now; sendKind("health", statusJson()); }
}
if (now - tLog >= 15000) {
tLog = now;
Sample& s = logBuf[logHead];
s.up = now / 1000; s.rssi = WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0; s.heap = ESP.getFreeHeap();
s.t = tempC; s.h = humid; s.led = ledOn; s.dhtRate = dhtRate(); s.pubFail = pubFail; s.drops = wifiDrops;
s.p = proto == "http" ? 'h' : 'm';
logHead = (logHead + 1) % LOG_N; if (logCount < LOG_N) logCount++;
}
uint32_t dt = micros() - t0;
if (dt > loopMaxUs && now > 10000) loopMaxUs = dt;
}
Halaman Simulator Kit & Lab Server berjalan penuh di browser tanpa server. Gunakan untuk pembekalan sebelum pelatihan, peserta yang kitnya bermasalah, atau demo di kelas. Perilaku yang ditirukan: MQTT dan HTTP (polling), keepalive dan LWT, transaksi dengan batas waktu, data hilang karena sinyal lemah, kabel sensor lepas, dan Uji Koneksi. Angkanya model, bukan pengukuran; bukti portofolio unit 028 tetap diambil dari kit fisik.
Sertifikat dibuat lewat Generator Sertifikat: isi data pelatihan dan penandatangan sekali, tempel daftar nama peserta, lalu Cetak Semua menjadi satu PDF (A4 mendatar, nomor urut otomatis, bisa dengan halaman lampiran materi). Rekap nomor bisa diunduh sebagai CSV untuk arsip.
Halaman ini tidak ditautkan dari halaman peserta. Karena web modul statis, siapa pun yang tahu alamatnya tetap bisa membukanya; bila ingin lebih aman, jangan unggah sertifikat.html ke VPS dan jalankan dari laptop instruktur saja (salin bersama folder images/ dan assets/).
Jendela Lab Server terbuka; http://IP-SERVER:8080 dapat dibuka dari HP lain di WiFi lab.
Satu kartu per peserta: WiFi lab, IP server, nama instance, password instance (lihat contoh di bawah).
Nyalakan setiap kit: WiFi KIT-IOT-xxxx muncul. Tempel label nomor instance.
Untuk demo Modul 1, sambungkan satu kit ke instance contoh (misalnya peserta-10) dan pastikan LED-nya terpasang.
Broker cloud (HiveMQ Cloud gratis atau Mosquitto di VPS) dengan akun jembatan dan akun pemantau; jembatan sudah pernah dicoba tersambung. Server lab butuh internet saat sesi ini.
Kabel USB data, power bank untuk pengukuran titik B dan C, multimeter, sensor cadangan.
LAB-IOT / ........192.168.1.10peserta-03iot03http://192.168.1.10:8080/i/peserta-03kit-01Jalankan dari Command Prompt di folder server-lab (atau pakai KIT-SIMULASI.bat untuk yang sederhana). Kit simulasi otomatis mengikuti protokol instance.
| Tujuan | Perintah |
|---|---|
| Kit simulasi normal | python labserver\kit_simulator.py --instance peserta-03 --password iot03 --count 5 |
| Ada data hilang & perintah gagal | ... --count 10 --loss 5 --no-ack 10 |
| Kit salah atur (forecast error +) | ... --count 3 --start 81 --interval 1 |
| Uji beban kapasitas | ... --count 50, lalu --count 100; amati "Pesan dibuang" di Halaman Instruktur |
| Login ditolak | Sambungkan MQTT Explorer ke instance peserta dengan password salah |
| Internet putus (Modul 6) | Cabut kabel WAN router 2 menit |
| Kit mati mendadak | Cabut USB salah satu kit peserta |
Hentikan simulator dengan Ctrl+C.
| Gejala | Penyebab Umum | Tindakan |
|---|---|---|
MULAI-SERVER.bat: "Python belum terpasang" | Python tidak masuk PATH | Pasang ulang Python, centang "Add python.exe to PATH" |
| Jendela server tertutup sendiri dengan pesan port terpakai | Program lain memakai port 1883/8080 (misalnya Mosquitto lama) | Hentikan layanan Mosquitto di Services, atau tutup program lain tersebut |
HP tidak bisa membuka http://IP-SERVER:8080 | Firewall / profil jaringan Public | Jalankan IZINKAN-FIREWALL.bat sebagai administrator; ubah jaringan ke Private |
| Kit tidak muncul di instance | IP server, nama atau password instance salah | Buka halaman kit → Pengaturan, perbaiki, simpan |
| Kit muncul di menu Kejadian sebagai LOGIN_DITOLAK | Password instance diganti setelah kit disetel | Setel ulang password di halaman kit |
| Uji Koneksi gagal | UDP 5005 / TCP 5006 diblok | Jalankan IZINKAN-FIREWALL.bat |
| Kartu "Koneksi cloud" bukan "tersambung" | Alamat/akun cloud salah, internet mati, paho-mqtt belum terpasang | Periksa isian di Halaman Instruktur; jalankan ulang MULAI-SERVER.bat saat ada internet |
| Laptop server tertidur | Pengaturan daya | Settings → System → Power: Sleep = Never selama pelatihan |