From 8a326f76e0983dd200e9f882f3ead188c24df931 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 6 May 2025 16:12:25 +1000 Subject: [PATCH] Initial Platformio project import --- .gitignore | 5 + .vscode/extensions.json | 10 + include/README | 37 ++ lib/README | 46 +++ platformio.ini | 20 ++ src/main.cpp | 757 ++++++++++++++++++++++++++++++++++++++++ test/README | 11 + 7 files changed, 886 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 include/README create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/main.cpp create mode 100644 test/README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..c726c3e --- /dev/null +++ b/platformio.ini @@ -0,0 +1,20 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +lib_deps = + ArduinoJson + Time + SparkFun VEML7700 + LoRa + sparkfun/SparkFun VEML7700 Arduino Library@^1.0.0 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c82a17f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,757 @@ +#include +#include +#include // For deep sleep functions +#include // For time functions +#include // For I2C +#include // VEML7700 +#include "WiFi.h" // Needed for disabling +#include "BluetoothSerial.h" // Needed for disabling +#include // LoRa library +#include // SPI for LoRa +#include // For receivedMessageIds +#include // For std::find_if + +// --- Pin Definitions --- +// LoRa Configuration +#define LORA_CS 5 +#define LORA_RST 14 +#define LORA_IRQ 26 // DIO0 - RTC_GPIO17 + +// Relay Configuration +const int relayPin = 12; + +// PIR sensor - RTC_GPIO14 +const int PIR_PIN = 13; + +// Voltage Sensor Configuration +const int voltageSensorPin = 34; +const float voltageDividerRatio = 5.0; +const float adcResolution = 3.3 / 4095.0; // Needs Calibration +const float calibrationFactor = 1.071; // Adjust as needed + +// --- Sensor Objects --- +VEML7700 veml = VEML7700(); + +// --- Device Configuration --- +const char* myDeviceName = "porch_light"; // *** RENAMED VARIABLE *** +const char* allLightsTarget = "all_lights"; +const char* allDevicesTarget = "all_devices"; + +// --- Configuration Constants --- +const unsigned long AWAKE_INACTIVITY_TIMEOUT = 30000; +const float MIN_LUX_THRESHOLD = 5.0; +const float MAX_LUX_THRESHOLD = 500.0; + +// --- RTC Memory Variables (Persistent State) --- +//RTC_DATA_ATTR bool settingsRequestMade = false; +RTC_DATA_ATTR time_t currentTime = 0; +RTC_DATA_ATTR time_t sunriseTime = 0; +RTC_DATA_ATTR time_t sunsetTime = 0; +RTC_DATA_ATTR float minimumPower = 11.0; +RTC_DATA_ATTR float maximumLight = 50.0; +RTC_DATA_ATTR unsigned long lightOnDuration = 30000; +RTC_DATA_ATTR String lightMode = "monitor"; +RTC_DATA_ATTR unsigned long sleepTime = 15 * 60 * 1000; +RTC_DATA_ATTR String lightStatus = "off"; +RTC_DATA_ATTR unsigned long motionDetectedTime = 0; +RTC_DATA_ATTR std::vector receivedMessageIds; + +// --- Volatile Variables (Reset on Wake) --- +bool isDaylight = true; +float lightReading = -1.0; +float powerReading = -1.0; +bool isSleeping = false; +unsigned long lastMessageReceivedTime = 0; + +// --- Function Declarations --- + +void initializeLoRa(); +void handleReceivedLoRaMessage(String receivedMessage); +void handlePirWakeup(); +void reportSensorsAndSleep(); +void sleepCycle(unsigned long durationMs); +void sendLoRaMessage(String jsonPayload); +void sendAcknowledgment(String message_id); +void sendStatusUpdate(String event_type); +void turnLightOn(bool triggeredByPir = false); +void turnLightOff(); +void updateDaylightStatus(); +float readVoltage(); +float readLightLevel(); +String createMessageId(); +void requestSettings(); +// --- End Function Declarations --- + +// --- Setup Function --- +// --- Setup Function --- +// --- Setup Function --- +void setup() { + Serial.begin(115200); + while (!Serial); + Serial.println("\n\n--- Driveway Light Controller (Pin Re-init Fix) ---"); + + // Disable GPIO hold FIRST + gpio_hold_dis(GPIO_NUM_5); + gpio_deep_sleep_hold_dis(); + + // --- Initialize Core Peripherals and Pins on EVERY Wake-up --- + Wire.begin(); // Initialize I2C (Using default pins 21, 22) + initializeLoRa(); // Initialize LoRa module (includes SPI.begin) + pinMode(relayPin, OUTPUT); // *** MOVED HERE: Re-init Relay Pin *** + pinMode(PIR_PIN, INPUT); // *** MOVED HERE: Re-init PIR Pin *** + // --- End Core Init --- + + esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause(); + + // *** FIX: Synchronize TimeLib from RTC Memory *** + if (currentTime != 0) { + // If currentTime (from RTC memory) has a valid value (not the initial 0) + setTime(currentTime); // Set the TimeLib internal clock + Serial.print("TimeLib synchronized from RTC currentTime: "); + Serial.println(currentTime); + } else { + // currentTime is still 0, meaning we haven't received a time update via LoRa yet. + Serial.println("RTC currentTime is 0. Waiting for LoRa time update."); + } + // *** END FIX *** + + // Now update daylight status using the potentially synchronized time + updateDaylightStatus(); + + // Restore relay state based on RTC variable AFTER pinMode is set + digitalWrite(relayPin, (lightStatus == "on") ? HIGH : LOW); + Serial.print("Restored relay state based on lightStatus ('"); + Serial.print(lightStatus); Serial.println("')"); + + bool enterLoopAwake = false; + + // --- SINGLE Switch Statement --- + switch(wakeup_reason) { + case ESP_SLEEP_WAKEUP_EXT1: { + uint64_t wakeup_pin_mask = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_pin_mask & (1ULL << LORA_IRQ)) { + Serial.println("Wakeup caused by EXT1 (LoRa IRQ)"); + enterLoopAwake = true; + } else if (wakeup_pin_mask & (1ULL << PIR_PIN)) { + Serial.println("Wakeup caused by EXT1 (PIR)"); + handlePirWakeup(); // Reads PIR state using digitalRead AFTER pinMode + // Stay awake if PIR turned light on in monitor mode + if (lightStatus == "on" && lightMode == "monitor") { + enterLoopAwake = true; + } + // If handlePirWakeup didn't turn light on, setup will decide sleep later + } else { + Serial.println("Wakeup caused by EXT1 (Unknown Pin)"); + // Go to sleep if unknown EXT1 source and light isn't meant to be on + if (lightStatus != "on") { + sleepCycle(sleepTime); + } else { + // If light is supposed to be on (e.g., mode=on), stay awake + enterLoopAwake = true; + } + } + break; // End of EXT1 case + } // End of EXT1 block + + case ESP_SLEEP_WAKEUP_TIMER: { + Serial.println("Wakeup caused by Timer"); + reportSensorsAndSleep(); // Reads sensors AFTER pinMode/Wire.begin + // reportSensorsAndSleep calls sleepCycle, so code execution stops here for this path + break; // End of TIMER case (technically unreachable due to sleep) + } // End of TIMER block + + default: { // Initial Boot or other reasons + Serial.printf("Initial Boot or other wake-up reason: %d\n", wakeup_reason); + // I2C, LoRa, pinMode already done above + if (veml.begin()) { Serial.println("VEML7700 connected"); } + else { Serial.println("VEML7700 not found"); } + WiFi.disconnect(true); WiFi.mode(WIFI_OFF); btStop(); + Serial.println("Initial boot setup complete."); + + // Check if essential time is missing + if (currentTime == 0 || sunriseTime == 0 || sunsetTime == 0) { + Serial.println("Essential time data missing on initial boot. Requesting settings."); + requestSettings(); // Call the function to send the request + Serial.println("Proceeding to loop to listen for settings response."); + enterLoopAwake = true; // Ensure we enter the loop + } else { + // Time data IS present from a previous run + Serial.println("Time data found in RTC. Reporting sensors and sleeping."); + reportSensorsAndSleep(); // Proceed with normal timer-like behavior + // reportSensorsAndSleep calls sleepCycle, so code execution stops here + } + break; // End of default case (may be unreachable if sleeping) + } // End of default block + + } // *** Closing brace for the SINGLE switch statement *** + + // --- Enter Loop Awake State --- + // This logic now runs *after* the switch statement is complete + if (enterLoopAwake || (lightStatus == "on")) { + Serial.println("Proceeding to loop in AWAKE state."); + isSleeping = false; + lastMessageReceivedTime = millis(); // Reset inactivity timer + LoRa.receive(); // Ensure LoRa is listening + } else if (!isSleeping) { // Add check to prevent redundant sleep call + Serial.println("Setup complete, conditions not met to stay awake. Entering sleep cycle."); + LoRa.receive(); + delay(10); + sleepCycle(sleepTime); + } + // If sleepCycle was called inside the switch, execution might not reach here. + +} // End of setup() + + + + +void requestSettings() { + Serial.println("Requesting settings from base station..."); + JsonDocument doc; + doc["device"] = myDeviceName; + doc["action"] = "request_settings"; // Use a specific action keyword + doc["message_id"] = createMessageId(); // Include a message ID + + String jsonData; + serializeJson(doc, jsonData); + sendLoRaMessage(jsonData); + Serial.println("Settings request sent."); + + // No need to manage flags here. + // The setup() function logic determines if we stay awake. +} + +// --- Main Loop --- +// --- Main Loop --- +void loop() { + bool processedLoRa = false; + + // 1. Check for LoRa messages FIRST + int packetSize = LoRa.parsePacket(); + if (packetSize) { + String msg = ""; + while (LoRa.available()) { msg += (char)LoRa.read(); } + handleReceivedLoRaMessage(msg); + lastMessageReceivedTime = millis(); + processedLoRa = true; + if (!isSleeping) { LoRa.receive(); } + else { return; } + } + + // 2. Check PIR Light Timeout (Only in monitor mode) + if (lightStatus == "on" && lightMode == "monitor" && motionDetectedTime > 0) { + if (millis() - motionDetectedTime > lightOnDuration) { + Serial.println("PIR light duration expired."); + turnLightOff(); + return; + } + } + + // 3. Check Low Power Condition (Only when lightMode is 'on') + if (lightStatus == "on" && lightMode == "on") { + static unsigned long lastPowerCheck = 0; + if (millis() - lastPowerCheck > 60000) { // Check every 60 seconds + lastPowerCheck = millis(); + powerReading = readVoltage(); + Serial.print("Periodic power check while ON: "); Serial.println(powerReading); + if (powerReading >= 0 && powerReading < minimumPower) { + Serial.println("LOW POWER DETECTED while lightMode=on. Turning off light."); + turnLightOff(); + return; + } + } + } + + // *** 4. ADDED: Check PIR Sensor while lightMode is 'on' *** + if (lightMode == "on") { + static bool lastPirState = LOW; // Remember last state to detect changes + static unsigned long lastPirReportTime = 0; + int currentPirState = digitalRead(PIR_PIN); + + if (currentPirState == HIGH && lastPirState == LOW) { + // Motion just started while lightMode is 'on' + Serial.println("PIR Motion Detected while lightMode=on."); + // Send a status update immediately + sendStatusUpdate("pir_motion_while_on"); + lastPirReportTime = millis(); // Record time of this report + } + // Optional: Send periodic updates if motion continues? + // else if (currentPirState == HIGH && lastPirState == HIGH) { + // // Motion continues + // if (millis() - lastPirReportTime > 60000) { // e.g., report every minute if motion persists + // sendStatusUpdate("pir_motion_continuing_while_on"); + // lastPirReportTime = millis(); + // } + // } + lastPirState = currentPirState; // Update last state + } + // *** END ADDED PIR CHECK *** + + + // 5. Check for Inactivity Timeout (Only relevant if NOT in 'on' mode) + // If mode is 'on', we stay awake indefinitely unless turned off by LoRa/Low Power + if (!processedLoRa && lightMode != "on" && lightStatus != "on") { + if (millis() - lastMessageReceivedTime > AWAKE_INACTIVITY_TIMEOUT) { + Serial.println("Awake inactivity timeout reached (monitor mode, light off). Going to sleep."); + sleepCycle(sleepTime); + return; + } + } + + delay(10); // Prevent tight loop +} + + +// --- Core Logic Functions --- + +void handlePirWakeup() { + Serial.println("Handling PIR Wakeup..."); + lightReading = readLightLevel(); + powerReading = readVoltage(); + updateDaylightStatus(); + + + Serial.print("PIR Trig: Daylight="); Serial.print(isDaylight); + + Serial.print("currentTime="); Serial.print(currentTime); + + Serial.print("sunriseTime="); Serial.print(sunriseTime); + Serial.print("sunsetTime="); Serial.print(sunsetTime); + + Serial.print(" | Light="); Serial.print(lightReading); + +Serial.print(" is less than maximumLight="); Serial.print(maximumLight); + + Serial.print(" | Power="); Serial.print(powerReading); + Serial.print(" | Mode="); Serial.println(lightMode); + + bool conditionsMet = (!isDaylight || (lightReading >= 0 && lightReading < maximumLight)) && + (powerReading >= 0 && powerReading > minimumPower) && + lightMode == "monitor"; + + if (conditionsMet) { + Serial.println("Conditions met for PIR light ON."); + turnLightOn(true); + sendStatusUpdate("pir_light_on"); + LoRa.receive(); + return; // Return to setup -> loop + } else { + Serial.println("Conditions NOT met for PIR light ON (or mode=on)."); + LoRa.receive(); // Ensure LoRa ready if setup enters loop + return; // Return to setup -> sleep decision + } +} + +void reportSensorsAndSleep() { + Serial.println("Timer wake-up: Reporting sensors..."); + lightReading = readLightLevel(); + powerReading = readVoltage(); + updateDaylightStatus(); + + JsonDocument doc; + doc["device"] = myDeviceName; // *** Use consistent "device" key *** + if (powerReading >= 0) doc["voltage"] = powerReading; + if (lightReading >= 0) doc["lux"] = lightReading; + doc["timestamp"] = now(); + doc["lightStatus"] = lightStatus; + doc["lightMode"] = lightMode; + doc["isDaylight"] = isDaylight; + doc["message_id"] = createMessageId(); + + String jsonData; + serializeJson(doc, jsonData); + sendLoRaMessage(jsonData); + sleepCycle(sleepTime); +} + +void sleepCycle(unsigned long durationMs) { + Serial.print("Entering sleep cycle for "); Serial.print(durationMs); Serial.println(" ms"); + isSleeping = true; + LoRa.receive(); // Put LoRa in receive mode before sleep + delay(10); + esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); // Disable all previous sources + + // --- Configure Wake-up Sources --- + + // 1. Timer Wake-up (Always Enabled) + esp_sleep_enable_timer_wakeup((uint64_t)durationMs * 1000); + Serial.print("Timer wake-up enabled for "); Serial.print(durationMs); Serial.println(" ms"); + + // 2. EXT1 Wake-up (ALWAYS Enable LoRa IRQ and PIR) + uint64_t ext1_wakeup_mask = (1ULL << LORA_IRQ) | (1ULL << PIR_PIN); + esp_sleep_enable_ext1_wakeup(ext1_wakeup_mask, ESP_EXT1_WAKEUP_ANY_HIGH); + Serial.println("EXT1 wake-up enabled for LoRa IRQ (26) and PIR (13) - ANY_HIGH"); + // --- End Configure Wake-up Sources --- + + // --- Hold CS pin HIGH during sleep --- + gpio_hold_en(GPIO_NUM_5); + gpio_deep_sleep_hold_en(); + + Serial.println("Entering deep sleep NOW..."); + Serial.flush(); + esp_deep_sleep_start(); + Serial.println("!!! Woke up from Deep Sleep Unexpectedly !!!"); // Should not see this +} + + +// --- LoRa Communication --- + +void initializeLoRa() { + Serial.print(F("[LoRa] Initializing ... ")); + SPI.begin(); + LoRa.setPins(LORA_CS, LORA_RST, LORA_IRQ); + if (!LoRa.begin(434E6)) { + Serial.println("Starting LoRa failed!"); + delay(1000); ESP.restart(); + } + LoRa.setSpreadingFactor(9); + LoRa.setSignalBandwidth(125E3); + LoRa.setCodingRate4(5); + LoRa.setPreambleLength(8); + LoRa.setSyncWord(0x34); + Serial.println(F("success!")); +} + +void sendLoRaMessage(String jsonPayload) { + Serial.print("Sending LoRa Message: "); Serial.println(jsonPayload); + LoRa.beginPacket(); + LoRa.print(jsonPayload); + int success = LoRa.endPacket(); + if (!success) { + Serial.println("LoRa transmit failed!"); + } +} + +void sendAcknowledgment(String message_id) { + JsonDocument ackDoc; + ackDoc["message_id"] = message_id; + ackDoc["message"] = "acknowledged"; + ackDoc["device"] = myDeviceName; // *** Use consistent "device" key *** + String ackMessage; + serializeJson(ackDoc, ackMessage); + Serial.print("Sending ACK for message ID: "); Serial.println(message_id); + sendLoRaMessage(ackMessage); +} + +void sendStatusUpdate(String event_type) { + JsonDocument statusDoc; + statusDoc["device"] = myDeviceName; // *** Use consistent "device" key *** + statusDoc["event"] = event_type; + statusDoc["lightStatus"] = lightStatus; + statusDoc["lightMode"] = lightMode; + statusDoc["message_id"] = createMessageId(); + if (powerReading >= 0) statusDoc["voltage"] = powerReading; + if (lightReading >= 0) statusDoc["lux"] = lightReading; + String statusMessage; + serializeJson(statusDoc, statusMessage); + sendLoRaMessage(statusMessage); +} + +// --- UPDATED Message Handler --- +// Handles the content of a received LoRa message +// Handles the content of a received LoRa message +// Handles the content of a received LoRa message +void handleReceivedLoRaMessage(String receivedMessage) { + Serial.print("handleReceivedLoRaMessage - Raw: "); Serial.println(receivedMessage); + + // Parse the incoming JSON FIRST + JsonDocument doc; // Use JsonDocument for parsing + DeserializationError error = deserializeJson(doc, receivedMessage); + if (error) { + Serial.print("Failed to parse JSON: "); Serial.println(error.c_str()); + return; // Exit if parsing fails + } + Serial.println("Parsed Received JSON"); + + // --- Always attempt to Update Settings --- + bool settingsUpdatedThisMessage = false; // Track if any setting was updated in THIS message + bool timeSettingsReceived = false; // Track if essential time settings came in this message + + if (doc["currentTime"].is()) { + currentTime = doc["currentTime"].as(); + setTime(currentTime); // Set TimeLib internal clock + updateDaylightStatus(); // Update daylight based on new time + settingsUpdatedThisMessage = true; + timeSettingsReceived = true; + Serial.println("Updated currentTime"); + } + if (doc["sunriseTime"].is()) { + sunriseTime = doc["sunriseTime"].as(); + settingsUpdatedThisMessage = true; + timeSettingsReceived = true; + Serial.println("Updated sunriseTime"); + } + if (doc["sunsetTime"].is()) { + sunsetTime = doc["sunsetTime"].as(); + settingsUpdatedThisMessage = true; + timeSettingsReceived = true; + Serial.println("Updated sunsetTime"); + } + if (doc["minimumPower"].is()) { + minimumPower = doc["minimumPower"].as(); + settingsUpdatedThisMessage = true; + Serial.println("Updated minimumPower"); + } + if (doc["maximumLightPercent"].is()) { + int percent = doc["maximumLightPercent"].as(); + percent = constrain(percent, 1, 100); + maximumLight = map(percent, 1, 100, (long)MIN_LUX_THRESHOLD, (long)MAX_LUX_THRESHOLD); + settingsUpdatedThisMessage = true; + Serial.print("Updated maximumLight from Percent("); Serial.print(percent); Serial.print("%) to Lux: "); Serial.println(maximumLight); + } + if (doc["lightOnDuration"].is()) { + unsigned long durationSeconds = doc["lightOnDuration"].as(); + lightOnDuration = durationSeconds * 1000UL; // Convert seconds to milliseconds + settingsUpdatedThisMessage = true; + Serial.print("Updated lightOnDuration from Seconds("); Serial.print(durationSeconds); Serial.print("s) to ms: "); Serial.println(lightOnDuration); + } + if (doc["sleepTime"].is()) { + unsigned long sleepSeconds = doc["sleepTime"].as(); + sleepTime = sleepSeconds * 1000UL; // Convert seconds to milliseconds + settingsUpdatedThisMessage = true; + Serial.print("Updated sleepTime from Seconds("); Serial.print(sleepSeconds); Serial.print("s) to ms: "); Serial.println(sleepTime); + } + // --- End Update Settings --- + + // Extract necessary fields FROM THE PARSED 'doc' + String message_id = doc["message_id"] | "no_id"; + String receivedTargetDevice = doc["targetDevice"] | "no_target"; + String action = doc["action"] | "no_action"; + + bool commandProcessed = false; + bool goingToSleep = false; + bool ackSent = false; + + // --- Target Check --- + bool targeted = (receivedTargetDevice.equals(myDeviceName) || + receivedTargetDevice.equals(allLightsTarget) || + receivedTargetDevice.equals(allDevicesTarget)); + + Serial.print("Message Target Check: Received='"); Serial.print(receivedTargetDevice); + Serial.print("', MyName='"); Serial.print(myDeviceName); + Serial.print("', Targeted="); Serial.println(targeted); + + if (!targeted) { + Serial.println("Message not targeted for this device."); + // Decide if we should sleep or keep listening if not targeted + if (lightMode != "on" && lightStatus != "on") { + // If light is off and mode isn't forced 'on', sleep + sleepCycle(sleepTime); + return; // Exit function after starting sleep + } else { + // Otherwise (light is on or mode is 'on'), stay awake and listen + LoRa.receive(); + return; // Exit function, stay awake + } + } + Serial.println("Message targeted for this device."); + + // --- Action Processing --- + // Send ACK *early* if possible and command is valid & targeted + // Acknowledge specific actions or any message that updated settings + if (message_id != "no_id") { + if (action == "turn_on" || action == "turn_off" || action == "apply_settings" || action == "request_settings" || settingsUpdatedThisMessage) { + sendAcknowledgment(message_id); + ackSent = true; + delay(50); // Small delay after ACK before potential status update + } + } + + if (action == "turn_on") { + Serial.println("Received action: turn_on"); + powerReading = readVoltage(); // Check power before turning on + if (powerReading >= 0 && powerReading < minimumPower) { + Serial.print("Cannot turn on - LOW POWER detected: "); Serial.println(powerReading); + sendStatusUpdate("turn_on_failed_low_power"); // Send specific status + commandProcessed = true; + goingToSleep = (lightMode != "on"); // Go to sleep unless mode is forced 'on' + } else { + if (lightStatus != "on") { + turnLightOn(); // Turn light on (sets mode to 'on' implicitly) + } else { + // If already on, ensure mode is 'on' + lightMode = "on"; + Serial.println("Light already on, ensuring mode is 'on'."); + } + commandProcessed = true; + // Stay awake because light is now on or mode is 'on' + } + } else if (action == "turn_off") { + Serial.println("Received action: turn_off"); + turnLightOff(); // This function handles status update and sleep + commandProcessed = true; + goingToSleep = true; // turnLightOff initiates sleep + // ACK was sent above + return; // Exit function as turnLightOff handles sleep + } else if (action == "apply_settings") { + Serial.println("Received action: apply_settings"); + // Logic using settingsRequestMade was removed here. + if (settingsUpdatedThisMessage) { + Serial.println("Settings values were updated from this message."); + } else { + Serial.println("Action 'apply_settings' received, but no new settings values were applied."); + } + commandProcessed = true; + // ACK was sent above + // Decide sleep based on current state after applying settings + goingToSleep = (lightMode != "on" && lightStatus != "on"); + } else if (action == "request_settings") { + Serial.println("Received action: request_settings (Ignoring, I am receiver)"); + // This device doesn't act on receiving a request, it sends requests. + commandProcessed = true; + // ACK was sent above + // Decide sleep based on current state + goingToSleep = (lightMode != "on" && lightStatus != "on"); + } else if (action != "no_action") { + Serial.println("Unknown action received: " + action); + if (message_id != "no_id" && !ackSent) { + // Send ACK for unknown action if not already sent + sendAcknowledgment(message_id); + ackSent = true; + } + commandProcessed = true; + // Decide sleep based on current state + goingToSleep = (lightMode != "on" && lightStatus != "on"); + } else { + // No specific 'action' key found in message. + Serial.println("No specific 'action' key found in message."); + if (settingsUpdatedThisMessage) { + Serial.println("Settings values were updated from message with no specific action."); + // ACK should have been sent above if message_id exists and settings were updated + commandProcessed = true; // Mark as processed if settings were updated + } + // Decide sleep based on current state + goingToSleep = (lightMode != "on" && lightStatus != "on"); + } + // --- End Action Processing --- + + + // --- Send Status Update --- + // Send status update only if a command was processed, we aren't going to sleep immediately, + // and it wasn't the turn_off command (which sends its own status before sleeping). + if (commandProcessed && !goingToSleep && action != "turn_off") { + // ACK should have already been sent if applicable + delay(1500); // Keep delay for status update separation from ACK + sendStatusUpdate("lora_command_processed"); + } else if (message_id == "no_id" && commandProcessed) { + Serial.println("Command processed but no message_id received, cannot ACK."); + } + + // --- Final Sleep/Wake Decision --- + if (goingToSleep) { + Serial.println("Command processed, conditions indicate sleep is appropriate."); + sleepCycle(sleepTime); + // Execution stops here + } else { + // Stay awake if light is on, mode is 'on', or loop inactivity timer hasn't expired + Serial.println("Command processed, staying awake in loop."); + lastMessageReceivedTime = millis(); // Reset inactivity timer as we processed a message + LoRa.receive(); // Ensure LoRa is ready for next message + } +} + + + +void updateDaylightStatus() { + if (sunriseTime == 0 || sunsetTime == 0 || currentTime == 0) { + Serial.println("updateDaylightStatus: Invalid time(s), defaulting to daylight=true"); + isDaylight = true; // Default to daylight if times are invalid to prevent unwanted ON state + return; + } + + // Handle the normal case where sunrise and sunset are on the same day + if (sunriseTime < sunsetTime) { + isDaylight = (currentTime > sunriseTime && currentTime < sunsetTime); + } + // Handle the case where times span midnight (sunrise is 'tomorrow' relative to sunset 'today') + else if (sunriseTime > sunsetTime) { + isDaylight = (currentTime > sunriseTime || currentTime < sunsetTime); + } + // If sunriseTime == sunsetTime (unlikely, but handle it) + else { + isDaylight = false; // Or true? Define behavior for this edge case. False = dark. + } + + Serial.print("updateDaylightStatus: currentTime="); Serial.print(currentTime); + Serial.print(", sunriseTime="); Serial.print(sunriseTime); + Serial.print(", sunsetTime="); Serial.print(sunsetTime); + Serial.print(" -> isDaylight="); Serial.println(isDaylight); +} + + + +float readVoltage() { + int adcValue = analogRead(voltageSensorPin); + const int numReadings = 5; + static float readings[numReadings]; + static int readIndex = 0; + static float total = 0; + static bool filled = false; + total = total - readings[readIndex]; + float rawVoltage = (float)adcValue * (3.3 / 4095.0) * voltageDividerRatio; + float calibratedVoltage = rawVoltage * calibrationFactor; // Apply calibration + readings[readIndex] = calibratedVoltage; + total = total + readings[readIndex]; + readIndex++; + if(readIndex >= numReadings) { readIndex = 0; filled = true; } + powerReading = filled ? total / (float)numReadings : total / (float)(readIndex == 0 ? 1 : readIndex); + return powerReading; +} + +float readLightLevel() { + Wire.begin(); // Re-init I2C + delay(20); + if (veml.begin()) { + lightReading = veml.getAmbientLight(); + } else { + lightReading = -1.0; + Serial.println("VEML7700 read failed (veml.begin() failed)."); + } + return lightReading; +} + +void turnLightOn(bool triggeredByPir /*= false*/) { + if (lightStatus == "off") { + digitalWrite(relayPin, HIGH); + lightStatus = "on"; + Serial.println("Light turned ON"); + if (triggeredByPir) { + motionDetectedTime = millis(); + Serial.print("PIR timer started for "); Serial.print(lightOnDuration); Serial.println(" ms"); + } else { + motionDetectedTime = 0; + lightMode = "on"; + Serial.println("Light mode set to: on"); + } + } else { + if (!triggeredByPir && lightMode != "on") { + lightMode = "on"; + Serial.println("Light already on, mode set to: on"); + } + } +} + +void turnLightOff() { + if (lightStatus == "on") { + digitalWrite(relayPin, LOW); + lightStatus = "off"; + motionDetectedTime = 0; + Serial.println("Light turned OFF"); + lightMode = "monitor"; + Serial.println("Light mode set to: monitor"); + sendStatusUpdate("light_turned_off"); + sleepCycle(sleepTime); + } else { + Serial.println("Light already OFF."); + if (lightMode != "monitor") { + lightMode = "monitor"; + Serial.println("Light mode set to: monitor"); + sendStatusUpdate("mode_set_monitor"); + } + sleepCycle(sleepTime); + } +} + +String createMessageId() { + return String(random(10000, 99999)); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html