/* * integrated_board.ino — Smart Reptile Habitat System * XIAO ESP32C6 / Integration Board v1 * * Pin assignments (Integration Board): * D6 → Intake fan PWM output (4-pin PC fan Pin4) * D7 → Exhaust fan PWM output (4-pin PC fan Pin4) * D8 → Intake fan TACH input (4-pin PC fan Pin3) * D9 → Exhaust fan TACH input (4-pin PC fan Pin3) * * MQTT topics: * Publish: reptile/sensor sensor + fan status JSON * Subscribe: reptile/intake/cmd "AUTO" | "ON[:speed]" | "OFF" * Subscribe: reptile/exhaust/cmd "AUTO" | "ON[:speed]" | "OFF" * * Pin assignments (Integration Board): * D0 → Relay CH2 (lighting) * D1 → Relay CH1 (heater) * * Devices active in this version: * [x] WiFi + MQTT + NTP (JST) * [x] SHT31 x2 (I2C 0x44 bottom / 0x45 top) * [x] Intake fan PWM + TACH * [x] Exhaust fan PWM + TACH * [x] Relay CH1 heater (D1, active HIGH) * [x] Relay CH2 lighting (D0, active HIGH, AUTO 06-20h) * [x] Grove Water Atomization (D2, HIGH=ON) * [ ] UV sensor — next * [ ] LED status — next */ #include #include #include #include #include // Declare before auto-generated prototypes see it in parseFanCmd signature enum FanMode { AUTO, MANUAL_ON, MANUAL_OFF }; // ── WiFi / MQTT ────────────────────────────────────────── #define WIFI_SSID "Omoshiro2G" #define WIFI_PASSWORD "qqqqqqqqqqqqq" #define MQTT_SERVER "192.168.11.201" #define MQTT_PORT 1883 #define MQTT_CLIENT_ID "reptile_integrated" #define TOPIC_SENSOR "reptile/sensor" #define TOPIC_INTAKE_CMD "reptile/fan/in/cmd" #define TOPIC_EXHAUST_CMD "reptile/fan/ex/cmd" #define PUBLISH_INTERVAL_MS 5000UL // ── Fan pins ───────────────────────────────────────────── #define FAN_INTAKE_PWM D6 #define FAN_EXHAUST_PWM D7 #define FAN_INTAKE_TACH D8 #define FAN_EXHAUST_TACH D9 #define PWM_FREQ 25000 // 4-pin PC fan standard: 25 kHz #define PWM_RES 8 // 8-bit: 0–255 // ── Relay pins (Grove 2-Channel SPDT, active HIGH) ─────── #define RELAY_HEATER D1 #define RELAY_LIGHTING D0 // ── Lighting auto schedule (JST hour, 24h) ─────────────── #define LIGHT_ON_HOUR 6 #define LIGHT_OFF_HOUR 20 // ── Grove Water Atomization (direct control, HIGH=ON) ──── #define HUMIDIFIER_PIN D2 // J2 Pin4 = Grove Pin1 (SIG) // ── Heater auto thresholds ─────────────────────────────── #define TEMP_HEAT_ON 24.0f // heater turns ON below this #define TEMP_HEAT_OFF 26.0f // heater turns OFF above this // ── Humidifier auto thresholds ─────────────────────────── #define HUM_HUM_ON 60.0f // humidifier turns ON below this #define HUM_HUM_OFF 70.0f // humidifier turns OFF above this // ── MQTT relay topics ──────────────────────────────────── #define TOPIC_HEATER_CMD "reptile/relay/heat/cmd" #define TOPIC_HUMIDIFIER_CMD "reptile/relay/hum/cmd" #define TOPIC_LIGHTING_CMD "reptile/relay/light/cmd" // ── Intake periodic schedule ───────────────────────────── #define INTAKE_RUN_MS (5UL * 60 * 1000) // 5 min ON #define INTAKE_INTERVAL_MS (60UL * 60 * 1000) // 60 min cycle #define INTAKE_SPEED 200 // ── Exhaust auto thresholds ────────────────────────────── #define TEMP_EX_ON 27.0f #define TEMP_EX_MAX 33.0f #define HUM_EX_ON 70.0f #define HUM_EX_MAX 85.0f #define EXHAUST_MIN 80 // ── TACH ───────────────────────────────────────────────── #define TACH_MEASURE_MS 1000UL // RPM 計測窓 #define PULSES_PER_REV 2 // 4-pin PC fan: 2 pulses/rev volatile uint32_t intakePulses = 0; volatile uint32_t exhaustPulses = 0; uint16_t intakeRpm = 0; uint16_t exhaustRpm = 0; unsigned long lastTachMeasure = 0; void IRAM_ATTR onIntakeTach() { intakePulses++; } void IRAM_ATTR onExhaustTach() { exhaustPulses++; } // ── globals ────────────────────────────────────────────── WiFiClient wifiClient; PubSubClient mqttClient(wifiClient); Adafruit_SHT31 sht31_bottom; Adafruit_SHT31 sht31_top; FanMode intakeMode = AUTO; FanMode exhaustMode = AUTO; uint8_t intakeManual = INTAKE_SPEED; uint8_t exhaustManual = 255; FanMode heaterMode = AUTO; bool heaterManual = false; bool heaterOn = false; FanMode humidifierMode = AUTO; bool humidifierManual = false; bool humidifierOn = false; FanMode lightingMode = AUTO; bool lightingManual = false; bool lightingOn = false; unsigned long lastPublish = 0; unsigned long intakeCycleStart = 0; // ── fan helpers ────────────────────────────────────────── // Initialize LEDC and TACH once at startup. void initFan(uint8_t pwmPin, uint8_t tachPin, void (*isr)()) { ledcAttach(pwmPin, PWM_FREQ, PWM_RES); ledcWrite(pwmPin, 0); pinMode(tachPin, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(tachPin), isr, FALLING); } // Set fan PWM duty (0=off signal, 255=full). LEDC stays attached always. void setFan(uint8_t pwmPin, uint8_t speed) { ledcWrite(pwmPin, speed); } uint8_t calcExhaustSpeed(float temp, float hum) { uint8_t sp = 0; if (temp > TEMP_EX_ON) { float r = (constrain(temp, TEMP_EX_ON, TEMP_EX_MAX) - TEMP_EX_ON) / (TEMP_EX_MAX - TEMP_EX_ON); sp = max(sp, (uint8_t)(EXHAUST_MIN + r * (255 - EXHAUST_MIN))); } if (hum > HUM_EX_ON) { float r = (constrain(hum, HUM_EX_ON, HUM_EX_MAX) - HUM_EX_ON) / (HUM_EX_MAX - HUM_EX_ON); sp = max(sp, (uint8_t)(EXHAUST_MIN + r * (255 - EXHAUST_MIN))); } return sp; } uint8_t updateIntakeAuto(unsigned long now) { unsigned long elapsed = now - intakeCycleStart; if (elapsed >= INTAKE_INTERVAL_MS) intakeCycleStart = now; return (elapsed < INTAKE_RUN_MS) ? INTAKE_SPEED : 0; } // ── TACH ───────────────────────────────────────────────── void updateRpm(unsigned long now) { if (now - lastTachMeasure < TACH_MEASURE_MS) return; float elapsed_s = (now - lastTachMeasure) / 1000.0f; lastTachMeasure = now; noInterrupts(); uint32_t ip = intakePulses; intakePulses = 0; uint32_t ep = exhaustPulses; exhaustPulses = 0; interrupts(); intakeRpm = (uint16_t)(ip / (float)PULSES_PER_REV / elapsed_s * 60.0f); exhaustRpm = (uint16_t)(ep / (float)PULSES_PER_REV / elapsed_s * 60.0f); } // ── sensor ─────────────────────────────────────────────── bool readSensor(Adafruit_SHT31& s, const char* label, float& t, float& h) { t = s.readTemperature(); h = s.readHumidity(); if (isnan(t) || isnan(h)) { Serial.printf("[SHT31] %s read error\n", label); t = h = 0.0f; return false; } return true; } // ── Relay helpers ───────────────────────────────────────── void setRelay(uint8_t pin, bool on) { digitalWrite(pin, on ? HIGH : LOW); } // Parse heater cmd: {"manual":true,"on":true} or "ON"/"OFF"/"AUTO" void parseHeaterCmd(const char* msg, FanMode& mode, bool& manOn) { const char* mp = strstr(msg, "\"manual\""); const char* op = strstr(msg, "\"on\""); if (mp && op) { bool manual = (strstr(mp, "true") != nullptr); bool on = (strstr(op + 5, "true") != nullptr); mode = manual ? MANUAL_ON : AUTO; manOn = on; return; } if (strcmp(msg, "ON") == 0) { mode = MANUAL_ON; manOn = true; } else if (strcmp(msg, "OFF") == 0) { mode = MANUAL_OFF; manOn = false; } else if (strcmp(msg, "AUTO") == 0) { mode = AUTO; } } // ── MQTT ───────────────────────────────────────────────── // Parse dashboard JSON: {"manual":true,"pwm":50,"schedule":{...}} void parseFanCmd(const char* msg, FanMode& mode, uint8_t& manualSpeed) { // JSON format from dashboard const char* mp = strstr(msg, "\"manual\""); const char* pp = strstr(msg, "\"pwm\""); if (mp && pp) { bool manual = (strstr(mp, "true") != nullptr); int pwmPct = atoi(pp + 6); // skip "pwm": if (!manual) { mode = AUTO; } else if (pwmPct > 0) { mode = MANUAL_ON; manualSpeed = (uint8_t)(pwmPct * 255 / 100); } else { mode = MANUAL_OFF; } return; } // Fallback: plain string format "ON[:speed]" / "OFF" / "AUTO" if (strncmp(msg, "ON", 2) == 0) { mode = MANUAL_ON; manualSpeed = (msg[2] == ':') ? (uint8_t)atoi(msg + 3) : 255; } else if (strcmp(msg, "OFF") == 0) { mode = MANUAL_OFF; } else if (strcmp(msg, "AUTO") == 0) { mode = AUTO; } } void mqttCallback(char* topic, byte* payload, unsigned int length) { char msg[32]; unsigned int len = min(length, (unsigned int)(sizeof(msg) - 1)); memcpy(msg, payload, len); msg[len] = '\0'; Serial.printf("[MQTT] %s: %s\n", topic, msg); if (strcmp(topic, TOPIC_INTAKE_CMD) == 0) parseFanCmd(msg, intakeMode, intakeManual); else if (strcmp(topic, TOPIC_EXHAUST_CMD) == 0) parseFanCmd(msg, exhaustMode, exhaustManual); else if (strcmp(topic, TOPIC_HEATER_CMD) == 0) { parseHeaterCmd(msg, heaterMode, heaterManual); if (heaterMode == MANUAL_ON) { heaterOn = heaterManual; setRelay(RELAY_HEATER, heaterOn); } else if (heaterMode == MANUAL_OFF) { heaterOn = false; setRelay(RELAY_HEATER, false); } Serial.printf("[Heater] mode=%d on=%d\n", heaterMode, heaterOn); } else if (strcmp(topic, TOPIC_HUMIDIFIER_CMD) == 0) { parseHeaterCmd(msg, humidifierMode, humidifierManual); if (humidifierMode == MANUAL_ON) { humidifierOn = humidifierManual; setRelay(HUMIDIFIER_PIN, humidifierOn); } else if (humidifierMode == MANUAL_OFF) { humidifierOn = false; setRelay(HUMIDIFIER_PIN, false); } Serial.printf("[Humidifier] mode=%d on=%d\n", humidifierMode, humidifierOn); } else if (strcmp(topic, TOPIC_LIGHTING_CMD) == 0) { parseHeaterCmd(msg, lightingMode, lightingManual); if (lightingMode == MANUAL_ON) { lightingOn = lightingManual; setRelay(RELAY_LIGHTING, lightingOn); } else if (lightingMode == MANUAL_OFF) { lightingOn = false; setRelay(RELAY_LIGHTING, false); } Serial.printf("[Lighting] mode=%d on=%d\n", lightingMode, lightingOn); } } void connectWiFi() { Serial.print("[WiFi] Connecting"); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.printf(" IP: %s\n", WiFi.localIP().toString().c_str()); } void connectMQTT() { while (!mqttClient.connected()) { Serial.print("[MQTT] Connecting..."); if (mqttClient.connect(MQTT_CLIENT_ID)) { bool s1 = mqttClient.subscribe(TOPIC_INTAKE_CMD); bool s2 = mqttClient.subscribe(TOPIC_EXHAUST_CMD); bool s3 = mqttClient.subscribe(TOPIC_HEATER_CMD); bool s4 = mqttClient.subscribe(TOPIC_HUMIDIFIER_CMD); bool s5 = mqttClient.subscribe(TOPIC_LIGHTING_CMD); Serial.printf(" connected. sub: in=%d ex=%d heat=%d hum=%d light=%d\n", s1, s2, s3, s4, s5); } else { Serial.printf(" failed rc=%d. Retry 5s.\n", mqttClient.state()); delay(5000); } } } // ── setup / loop ────────────────────────────────────────── void setup() { Serial.begin(115200); delay(500); Serial.println("=== Reptile Habitat Integration Board ==="); // Relay — start OFF pinMode(RELAY_HEATER, OUTPUT); digitalWrite(RELAY_HEATER, LOW); pinMode(RELAY_LIGHTING, OUTPUT); digitalWrite(RELAY_LIGHTING, LOW); // Grove Water Atomization — start OFF (D2=SIG HIGH=ON, D3=NC) pinMode(HUMIDIFIER_PIN, OUTPUT); digitalWrite(HUMIDIFIER_PIN, LOW); // Fan PWM — LEDC + TACH initialized once here initFan(FAN_INTAKE_PWM, FAN_INTAKE_TACH, onIntakeTach); initFan(FAN_EXHAUST_PWM, FAN_EXHAUST_TACH, onExhaustTach); // I2C sensors Wire.setPins(22, 23); Wire.begin(); if (!sht31_bottom.begin(0x44)) Serial.println("[SHT31] Bottom not found!"); else Serial.println("[SHT31] Bottom OK."); if (!sht31_top.begin(0x45)) Serial.println("[SHT31] Top not found!"); else Serial.println("[SHT31] Top OK."); // MQTT mqttClient.setBufferSize(512); // default 256 is too small for our sensor payload mqttClient.setServer(MQTT_SERVER, MQTT_PORT); mqttClient.setCallback(mqttCallback); connectWiFi(); configTime(9 * 3600, 0, "pool.ntp.org", "ntp.jst.mfeed.ad.jp"); // JST = UTC+9 Serial.print("[NTP] Syncing"); time_t now = 0; for (int i = 0; i < 20 && now < 1000000; i++) { delay(500); Serial.print("."); time(&now); } Serial.printf(" %s\n", now > 1000000 ? "OK" : "FAIL (will retry)"); connectMQTT(); intakeCycleStart = millis(); lastTachMeasure = millis(); Serial.println("[Setup] Done."); } void loop() { if (WiFi.status() != WL_CONNECTED) { Serial.println("[WiFi] Reconnecting..."); connectWiFi(); } if (!mqttClient.connected()) connectMQTT(); mqttClient.loop(); unsigned long now = millis(); updateRpm(now); if (now - lastPublish < PUBLISH_INTERVAL_MS) return; lastPublish = now; // Sensors float temp_b, hum_b, temp_t, hum_t; bool valid_b = readSensor(sht31_bottom, "Bottom", temp_b, hum_b); readSensor(sht31_top, "Top", temp_t, hum_t); // Intake fan uint8_t inSpeed = 0; switch (intakeMode) { case AUTO: inSpeed = updateIntakeAuto(now); break; case MANUAL_ON: inSpeed = intakeManual; break; case MANUAL_OFF: inSpeed = 0; break; } setFan(FAN_INTAKE_PWM, inSpeed); // Exhaust fan uint8_t exSpeed = 0; switch (exhaustMode) { case AUTO: exSpeed = valid_b ? calcExhaustSpeed(temp_b, hum_b) : 0; break; case MANUAL_ON: exSpeed = exhaustManual; break; case MANUAL_OFF: exSpeed = 0; break; } setFan(FAN_EXHAUST_PWM, exSpeed); // Heater relay switch (heaterMode) { case AUTO: if (valid_b) { if (temp_b < TEMP_HEAT_ON) heaterOn = true; if (temp_b > TEMP_HEAT_OFF) heaterOn = false; } break; case MANUAL_ON: heaterOn = heaterManual; break; case MANUAL_OFF: heaterOn = false; break; } setRelay(RELAY_HEATER, heaterOn); // Humidifier relay switch (humidifierMode) { case AUTO: if (valid_b) { if (hum_b < HUM_HUM_ON) humidifierOn = true; if (hum_b > HUM_HUM_OFF) humidifierOn = false; } break; case MANUAL_ON: humidifierOn = humidifierManual; break; case MANUAL_OFF: humidifierOn = false; break; } setRelay(HUMIDIFIER_PIN, humidifierOn); // Lighting relay switch (lightingMode) { case AUTO: { time_t now = time(nullptr); struct tm* t = localtime(&now); lightingOn = (t->tm_hour >= LIGHT_ON_HOUR && t->tm_hour < LIGHT_OFF_HOUR); break; } case MANUAL_ON: lightingOn = lightingManual; break; case MANUAL_OFF: lightingOn = false; break; } setRelay(RELAY_LIGHTING, lightingOn); // Publish const char* modeStr[] = {"AUTO", "ON", "OFF"}; char payload[480]; snprintf(payload, sizeof(payload), "{\"temp_b\":%.1f,\"hum_b\":%.1f,\"temp_t\":%.1f,\"hum_t\":%.1f," "\"fan_in_pwm\":%d,\"fan_ex_pwm\":%d," "\"fan_in_rpm\":%d,\"fan_ex_rpm\":%d," "\"intake_mode\":\"%s\",\"exhaust_mode\":\"%s\"," "\"heater_on\":%d,\"heater_mode\":\"%s\"," "\"humidifier_on\":%d,\"humidifier_mode\":\"%s\"," "\"lighting_on\":%d,\"lighting_mode\":\"%s\"}", temp_b, hum_b, temp_t, hum_t, inSpeed * 100 / 255, exSpeed * 100 / 255, intakeRpm, exhaustRpm, modeStr[intakeMode], modeStr[exhaustMode], heaterOn ? 1 : 0, modeStr[heaterMode], humidifierOn ? 1 : 0, modeStr[humidifierMode], lightingOn ? 1 : 0, modeStr[lightingMode]); Serial.print("[MQTT] "); Serial.print(payload); Serial.println(mqttClient.publish(TOPIC_SENSOR, payload) ? " [OK]" : " [FAIL]"); }