דפי אינטרנט ו-MQTT — מהדפדפן אל הבקר

✦ בנוי על ידי פהד גאנם ✦

<span id="temp" dir="ltr">24.6 °C</span>
1.5
<span>
—
—
—
—
—
—
—
—
—
—

<body>
  <h1>בקר החממה</h1>
  <ul>
    <li>טמפרטורה: <span id="temp" dir="ltr">24.6 °C</span></li>
    <li>מאוורר: <span id="fanState">כבוי</span></li>
  </ul>
  <hr>
  <button id="fan">הפעל מאוורר</button>
</body>
1 + 1 + 1 + 2 + 2 + 1 + 1 = 9
9 − 8 = 1
html › body › ul › li › span
HTML
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>בקר החממה</title>
</head>
<body>
  <h1>בקר החממה</h1>
  <p>טמפרטורה: <span id="temp" dir="ltr">24.6 °C</span></p>
  <p>מאוורר: <span id="fanState">כבוי</span></p>
  <button id="fan">הפעל מאוורר</button>
</body>
</html>

Wvis = width + 2·padding + 2·border
Wtot = Wvis + 2·margin
200 px
12 px
2 px
8 px
box-sizing
p
span { color: blue; }—
.val { color: orange; }—
#temp { color: red; }—
—
—
—
—
—
—
—
—
—

Wvis = width + 2·padding + 2·border = 200 + 2·12 + 2·2 = 228 px
Wtot = Wvis + 2·margin = 228 + 2·8 = 244 px
Wvis = width = 200 px
Wcontent = width − 2·padding − 2·border = 200 − 2·12 − 2·2 = 172 px
Wtot = Wvis + 2·margin = 200 + 2·8 = 216 px
ΔW = 2·padding + 2·border = 2·12 + 2·2 = 28 px
HTML · CSS
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>בקר החממה</title>
  <style>
    /* tag selectors: every element of that kind */
    body {
      font-family: Arial, sans-serif;
      background: white;
      color: #37474f;
    }
    h1 {
      color: #2e7d32;
      font-size: 24px;
    }
    span {
      color: #1565c0;
      font-weight: bold;
    }

    /* class selector: every element that carries class="card" */
    .card {
      box-sizing: border-box;      /* width now includes padding and border */
      width: 240px;
      margin: 16px auto;           /* auto on both sides centres the box */
      padding: 12px;
      border: 2px solid #2e7d32;
      border-radius: 10px;
      background: #f1f8e9;
      text-align: center;
    }

    /* id selector: one element only, and it beats the span rule above */
    #temp {
      color: #c62828;
    }

    button {
      background: #2e7d32;
      color: white;
      font-size: 16px;
      padding: 10px 18px;
      border: 2px solid #1b5e20;
      border-radius: 8px;
    }
    /* applies only while the pointer is over the button */
    button:hover {
      background: #43a047;
    }
  </style>
</head>
<body>
  <div class="card">
    <h1>בקר החממה</h1>
    <p>טמפרטורה: <span id="temp" dir="ltr">24.6 °C</span></p>
    <p>מאוורר: <span id="fanState">כבוי</span></p>
    <button id="fan">הפעל מאוורר</button>
  </div>
</body>
</html>

tms = 1000 · T
N = 60 sT

2.0 s
25.0 °C

—
0
—
0

<p>מאוורר: <span id="fanState">כבוי</span></p>
<p>לחיצות: <span id="count">0</span></p>
<button id="fan">הפעל מאוורר</button>
<script>
  let fanOn = false, clicks = 0;
  const ON = 'פועל', OFF = 'כבוי';
  const state = document.getElementById('fanState');
  const count = document.getElementById('count');
  document.getElementById('fan').addEventListener('click', function () {
    fanOn = !fanOn;
    clicks = clicks + 1;
    state.textContent = fanOn ? ON : OFF;
    count.textContent = clicks;
  });
</script>
fanOn = !false = true
clicks = 0 + 1 = 1
fanOn = !true = false
clicks = 1 + 1 = 2
fanOn = !false = true
clicks = 2 + 1 = 3
tms = 1000 · T = 1000 · 2 = 2000 ms
setInterval(update, 2000);
N = 60 sT = 60 s2 s = 30
HTML · CSS · JavaScript
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>בקר החממה</title>
  <style>
    body   { font-family: Arial, sans-serif; background: white; color: #263238; }
    .card  { box-sizing: border-box; width: 260px; margin: 16px auto; padding: 12px;
             border: 2px solid #2e7d32; border-radius: 10px; background: #f1f8e9;
             text-align: center; }
    h1     { color: #2e7d32; font-size: 24px; }
    span   { color: #1565c0; font-weight: bold; }
    button { background: #2e7d32; color: white; font-size: 16px; padding: 10px 18px;
             border: 2px solid #1b5e20; border-radius: 8px; }
    button:disabled { background: #9e9e9e; border-color: #757575; }
    /* the class that the script adds and removes */
    .on    { color: #2e7d32; }
  </style>
</head>
<body>
  <div class="card">
    <h1>בקר החממה</h1>
    <p>טמפרטורה: <span id="temp" dir="ltr">24.6 °C</span></p>
    <p>ערך רצוי: <span id="setOut" dir="ltr">25 °C</span></p>
    <input type="range" id="set" min="20" max="30" step="0.5" value="25">
    <p><label><input type="checkbox" id="auto"> בקרה אוטומטית</label></p>
    <p>מאוורר: <span id="fanState">כבוי</span></p>
    <button id="fan">הפעל מאוורר</button>
  </div>

  <script>
    // state: lives in the page and starts again on every refresh
    let fanOn = false;
    let ticks = 0;
    let temp  = 24.6;

    // the words the page shows
    const ON = 'פועל', OFF = 'כבוי';
    const START = 'הפעל מאוורר', STOP = 'כבה מאוורר';

    // the elements, found once by id
    const tempOut  = document.getElementById('temp');
    const slider   = document.getElementById('set');
    const setOut   = document.getElementById('setOut');
    const autoBox  = document.getElementById('auto');
    const fanState = document.getElementById('fanState');
    const fanBtn   = document.getElementById('fan');

    // the only function that writes to the page
    function show() {
      tempOut.textContent  = temp.toFixed(1) + ' °C';
      setOut.textContent   = slider.value + ' °C';
      fanState.textContent = fanOn ? ON : OFF;
      fanState.classList.toggle('on', fanOn);
      fanBtn.textContent   = fanOn ? STOP : START;
      fanBtn.disabled      = autoBox.checked;
    }

    // automatic mode: a threshold with a band of 1 degree on each side
    function decide() {
      if (autoBox.checked) {
        const set = Number(slider.value);
        if (temp > set + 1) fanOn = true;
        if (temp < set - 1) fanOn = false;
      }
      show();
    }

    // events: the browser calls these functions, the page never waits in a loop
    fanBtn.addEventListener('click', function () {
      fanOn = !fanOn;
      show();
    });
    slider.addEventListener('input', decide);
    autoBox.addEventListener('change', decide);

    // timer: once a second an invented reading, a slow wave around 24.6,
    // computed in tenths of a degree and rounded
    setInterval(function () {
      ticks = ticks + 1;
      temp = Math.round(246 + 24 * Math.sin(2 * Math.PI * ticks / 40)) / 10;
      decide();
    }, 1000);

    show();
  </script>
</body>
</html>

http://
scheme
192.168.1.50
host
:80
port
/fan
path
?state=on
query
Nmin = 60 sT
Nh = 3600 sT
2.0 s
—
0
—
—
—
—


scheme: http
host: 192.168.1.50
port: 80
path: /fan
query: state=on

server.on("/fan", handleFan);
server.arg("state") == "on"

HTTP/1.1 200 OK Content-Type: text/plain on

Nmin = 60 sT = 60 s2 s = 30

Nh = 3600 sT = 3600 s2 s = 1800
ESP32 · Arduino C++
// 4  The greenhouse controller as a web server.
// The browser asks and the ESP32 answers:
//   /               returns the page
//   /fan?state=on   switches the fan (or state=off) and answers with a short text
//   /data           returns the reading as JSON
// Any other path gets the status code 404.

#include <WiFi.h>
#include <WebServer.h>

const char* WIFI_NAME = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";

const int PIN_SENSOR = 34;   // LM35 output, 10 mV per degC
const int PIN_FAN    = 26;   // gate of the MOSFET that switches the fan
const int PIN_LED    = 2;    // built-in LED, mirrors the fan

WebServer server(80);        // HTTP listens on port 80
bool fanOn = false;

// The whole page, kept in flash as one raw string literal.
const char PAGE[] = R"rawliteral(
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head><meta charset="utf-8"><title>בקר החממה</title>
<meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="font-family: sans-serif; background: white">
<h1>בקר החממה</h1>
<p>טמפרטורה: <span id="temp" dir="ltr">-- °C</span></p>
<p>מאוורר: <span id="fanState">כבוי</span></p>
<button id="fan">הפעל מאוורר</button>
<script>
function el(id) { return document.getElementById(id); }
var next = "on";
var STATE = { on: "פועל", off: "כבוי" };
var BUTTON = { on: "כבה מאוורר", off: "הפעל מאוורר" };
function show(d) {
  next = (d.fan == "on") ? "off" : "on";
  el("temp").textContent = d.temp.toFixed(1) + " °C";
  el("fanState").textContent = STATE[d.fan];
  el("fan").textContent = BUTTON[d.fan];
}
function lost() { el("fanState").textContent = "אין חיבור לבקר"; }
function refresh() { fetch("/data").then(r => r.json()).then(show).catch(lost); }
el("fan").onclick = function () { fetch("/fan?state=" + next).then(refresh).catch(lost); };
refresh(); setInterval(refresh, 2000);
</script>
</body></html>
)rawliteral";

float readTemperature() {
  return analogReadMilliVolts(PIN_SENSOR) / 10.0;
}

void setFan(bool on) {
  fanOn = on;
  digitalWrite(PIN_FAN, on ? HIGH : LOW);
  digitalWrite(PIN_LED, on ? HIGH : LOW);
}

// GET /  :  the page itself
void handleRoot() {
  server.send(200, "text/html", PAGE);
}

// GET /fan?state=on  or  GET /fan?state=off  :  switch, then answer with the new state
void handleFan() {
  String state = server.arg("state");
  if (state == "on")  setFan(true);
  if (state == "off") setFan(false);
  server.send(200, "text/plain", fanOn ? "on" : "off");
}

// GET /data  :  {"temp": 24.6, "fan": "on"}
void handleData() {
  String json = "{\"temp\": ";
  json += String(readTemperature(), 1);
  json += ", \"fan\": \"";
  json += fanOn ? "on" : "off";
  json += "\"}";
  server.send(200, "application/json", json);
}

// Any other path  :  404
void handleNotFound() {
  server.send(404, "text/plain", "Not Found");
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_FAN, OUTPUT);
  pinMode(PIN_LED, OUTPUT);
  setFan(false);

  WiFi.begin(WIFI_NAME, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Type this address in the browser: http://");
  Serial.println(WiFi.localIP());

  server.on("/", handleRoot);
  server.on("/fan", handleFan);
  server.on("/data", handleData);
  server.onNotFound(handleNotFound);
  server.begin();
}

void loop() {
  server.handleClient();   // answers one waiting request, if there is one
}
HTTP
GET /data HTTP/1.1
Host: 192.168.1.50

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 27

{"temp": 24.6, "fan": "on"}

N = 2 + 2 + Lt + Lp
N = 2 + 2 + 15 + 4 = 23 B
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—
—

greenhouse/fan/state
Lt = 10 + 1 + 3 + 1 + 5 = 20
Lp = 2
N = 2 + 2 + Lt + Lp = 2 + 2 + 20 + 2 = 26 B
MQTT
// MQTT 3.1.1 - the dialogue of the greenhouse, written out as text.
// A reference to read, not a program to run.
// Broker: 192.168.1.10, TCP port 1883.
// C = controller gh-esp32     B = broker     P = phone dashboard

C -> B   CONNECT     client id "gh-esp32", clean session           22 bytes
B -> C   CONNACK     connection accepted                            4 bytes

C -> B   SUBSCRIBE   "greenhouse/fan/set"
B -> C   SUBACK      subscription accepted

P -> B   SUBSCRIBE   "greenhouse/#"
B -> P   SUBACK      subscription accepted

C -> B   PUBLISH     "greenhouse/temp"        payload 24.6         23 bytes
B -> P   PUBLISH     "greenhouse/temp"        payload 24.6         23 bytes

P -> B   PUBLISH     "greenhouse/fan/set"     payload on           24 bytes
B -> C   PUBLISH     "greenhouse/fan/set"     payload on           24 bytes
B -> P   PUBLISH     "greenhouse/fan/set"     payload on           24 bytes

C -> B   PUBLISH     "greenhouse/fan/state"   payload on           26 bytes
B -> P   PUBLISH     "greenhouse/fan/state"   payload on           26 bytes

C -> B   PINGREQ     nothing to send, the link stays alive          2 bytes
B -> C   PINGRESP                                                   2 bytes

twill = 1.5 · TKA
15 s
0
0
—
off
—
—
—
15 s
0.0 s
22.5 s
online

twill = 1.5 · TKA
twill = 1.5 · TKA = 1.5 · 15 s = 22.5 s
n = twillTpub = 22.5 s5 s = 4.5
N0 = 1
N1 = 1 + 1 = 2
N2 = 1 + 3 = 4
ESP32 · Arduino C++ · PubSubClient
// 6  Greenhouse controller on MQTT: last will, retained state, JSON.
// Library needed: PubSubClient (Arduino IDE, Library Manager).
// The broker runs on the lab computer; the ESP32 only knows its address.

#include <WiFi.h>
#include <PubSubClient.h>

const char* WIFI_SSID   = "YOUR_WIFI";
const char* WIFI_PASS   = "YOUR_PASSWORD";
const char* BROKER      = "192.168.1.10";
const int   BROKER_PORT = 1883;

const int PIN_LM35 = 34;   // ADC1 input, LM35 gives 10 mV per degC
const int PIN_FAN  = 26;   // gate of the logic-level MOSFET
const int PIN_LED  = 2;    // built-in LED mirrors the fan

WiFiClient   net;
PubSubClient mqtt(net);

bool          fanOn    = false;
unsigned long lastTry  = 0;   // last connection attempt [ms]
unsigned long lastRead = 0;   // last temperature publish [ms]

// Retained: a dashboard that opens later gets the state at once.
void publishFanState() {
  mqtt.publish("greenhouse/fan/state", fanOn ? "on" : "off", true);
}

void setFan(bool on) {
  fanOn = on;
  digitalWrite(PIN_FAN, on ? HIGH : LOW);
  digitalWrite(PIN_LED, on ? HIGH : LOW);
  publishFanState();
}

// The payload is NOT zero-terminated, so strcmp() on it would read past
// the end. Compare the length first, then the bytes.
void onMessage(char* topic, byte* payload, unsigned int length) {
  if (strcmp(topic, "greenhouse/fan/set") != 0) return;
  if (length == 2 && memcmp(payload, "on", 2) == 0)  setFan(true);
  if (length == 3 && memcmp(payload, "off", 3) == 0) setFan(false);
}

// One attempt every 2 s. It returns at once, so loop() never stalls.
void reconnect() {
  if (WiFi.status() != WL_CONNECTED) return;
  if (millis() - lastTry < 2000) return;
  lastTry = millis();

  // connect(client id, will topic, will QoS, will retain, will message)
  if (mqtt.connect("gh-esp32", "greenhouse/status", 1, true, "offline")) {
    mqtt.publish("greenhouse/status", "online", true);
    mqtt.subscribe("greenhouse/fan/set");
    publishFanState();
    Serial.println("MQTT connected");
  } else {
    Serial.print("MQTT failed, state ");
    Serial.println(mqtt.state());
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_FAN, OUTPUT);
  pinMode(PIN_LED, OUTPUT);
  digitalWrite(PIN_FAN, LOW);
  digitalWrite(PIN_LED, LOW);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);   // connects in the background

  mqtt.setServer(BROKER, BROKER_PORT);
  mqtt.setCallback(onMessage);
}

void loop() {
  if (!mqtt.connected()) reconnect();
  mqtt.loop();   // receives messages and keeps the connection alive

  if (mqtt.connected() && millis() - lastRead >= 5000) {
    lastRead = millis();
    float tempC = analogReadMilliVolts(PIN_LM35) / 10.0;

    char text[8];
    dtostrf(tempC, 1, 1, text);                 // "24.6"
    mqtt.publish("greenhouse/temp", text);      // QoS 0, not retained

    char json[48];
    snprintf(json, sizeof(json), "{\"temp\":%.1f,\"fan\":\"%s\"}",
             tempC, fanOn ? "on" : "off");
    mqtt.publish("greenhouse/data", json);      // {"temp":24.6,"fan":"on"}
  }
}
JSON
{"temp":24.6,"fan":"on"}

🏠 ♿