Evolving Drupal's Layout Builder to an Experience Builder

3 days 15 hours ago

Imagine a world where installing Drupal instantly offers a creative experience that allows you to build and style pages right out of the box, without any need for additional modules or configuration.

The introduction of Drupal's Layout Builder in 2018 was an important milestone toward this vision, but it was just the first step. Layout Builder provides site builders with a powerful drag-and-drop interface for creating and arranging content within customizable layouts.

Despite its success, there is a clear and pressing need to improve the existing Layout Builder. The numerous community-developed modules enhancing Layout Builder highlight the need for a more comprehensive solution.

That is why at DrupalCon Lille last year, I was excited to announce the "Next Generation Page Builder" initiative, aimed at improving and expanding the Layout Builder to provide a truly intuitive, out-of-the-box page-building experience.

Since announcing the 'Next Generation Page Builder', led by Lauri Eskola (Acquia), a Drupal Core Committer, we've done extensive research and planning.

Inspired by user feedback, we decided to make two changes. First, we decided to broaden our focus: not only will we improve the page-building features of Layout Builder, we will also integrate basic theming capabilities, enabling users to style their pages effortlessly without having to edit Twig files. Second, reflecting on this wider scope, we renamed the initiative from 'Next Generation Page Builder' to 'Experience Builder'.

In recent months, we explored several options for how to create such an Experience Builder, including accelerating development of the Layout Builder, switching to Gutenberg, adopting Paragraphs, or using the newly open-sourced Plasmic.

After thorough analysis and discussions with key stakeholders, including Automattic's Gutenberg team, the Drupal Core Committers decided the best approach is to expand the Layout Builder while also incorporating the best elements of Paragraphs.

Looking to the future, I hope the Experience Builder becomes the preferred Drupal tool for layout design, page building, and basic theming. Our main goal is to create a tool that site builders love, with an amazing out-of-the-box experience. By integrating key features from Paragraphs, we also aim to create a unified solution that reduces fragmentation, accelerates innovation, and ensures Drupal remains at the forefront of site building.

Our future success hinges on expanding Drupal's usability to a wider audience. Our CMS capabilities are often better than our competitors', but aren't always as user friendly. In the Drupal 7 era, Drupal was the OG (Original Great) of low-code but today we are being outpaced by competitors in terms of ease of use. Without user experience improvements, we'll lose ground. The Experience Builder initiative is all about introducing more people to the power of Drupal.

I feel strongly that a unified Experience Builder is one of the most important initiatives we can undertake right now.

Developing an Experience Builder is a big task that will require substantial effort, extensive collaboration, and significant expertise in user experience and design. As Drupal Core Committers, we are driven by a sense of urgency to advance this initiative. We are committed to moving quickly and iterating rapidly, but to succeed, we also need your support. There will be many opportunities for the community to collaborate and contribute to this initiative.

For more information, please check Lauri's latest blog post on the topic. Additionally, I will discuss this further in my upcoming DrupalCon Portland keynote in a few weeks.

Dries

Building my own CO2 monitor

2 weeks 5 days ago

For years, I have worried about the CO2 levels in our kids' bedroom. Until recently, our two sons shared a small bedroom in our apartment. Every night, they insisted on shutting the door to block out light and noise. Yet, once they fell asleep, I'd quietly open the door to make sure they had enough fresh air to fuel their dreams.

As we breathe, our bodies naturally expel CO2 (carbon dioxide). When CO2 reacts with water within our body it becomes carbonate, which can subtly shift our body's internal balance. That is why high CO2 levels, like in sealed bedrooms, can be harmful.

Outdoor CO2 levels average around 400 ppm (parts per million), but indoor levels are considered healthy up to 800 ppm. Between 800 and 1200 ppm, minor discomfort may begin, and levels above 2,000 ppm indicate poor air quality, posing health risks.

A pivotal study by Harvard University found that for every 500 ppm increase in CO2, cognitive response times slow by 1.4-1.8%, and productivity decreases by 2.1-2.4%. Furthermore, another study links high CO2 levels to reduced sleep quality. These findings highlight the effects of indoor CO2 levels on both our physical health, mental performance and sleep quality.

After developing my own thermometer, I grew interested in CO2 monitoring. Although there are many commercial CO2 detectors available, I opted to build my own CO2 monitor using my thermometer project as the starting point. Replacing the temperature and humidity sensor with a CO2 sensor was a straightforward process.

It did require a deep dive into CO2 sensors, which led me to the Sensirion SCD41 sensor. Unlike many other CO2 sensors that merely estimate CO2 levels, the SCD41 sensor utilizes advanced photoacoustic NDIR technology to accurately measure the actual CO2 concentrations. According to the documentation:

The SCD4x series is based on photoacoustic NDIR technology. The technology exploits the characteristic property of CO2 molecules to strongly absorb infra-red (IR) light with wavelengths around 4.2 µm. When shining light of this wavelength through a gas sample, the CO2 concentration can thus be calculated from the proportion of light that is absorbed. A development board with an ESP32-S3 chip (left) connected to a Sensirion SCD41 sensor on the right, which measures CO2 levels and temperature.

My ESP32 device measures CO2 levels every few minutes, connects to WiFi and sends this data to my web service endpoint at https://dri.es/sensors. This endpoint processes and visualizes the data. Unlike our basement temperature, I've chosen to keep the CO2 data private and not available to the public.

After I updated the client code on the ESP32 development board to use the new sensor, I also had to make small adjustments to the backend code used for data visualization.

Once I got everything working, I sneaked my project into our bedroom, sidestepping any objections by Vanessa, about turning our bedroom into a gadget lab.

The next morning, I was met with some surprising data: CO2 levels had spiked to 2,500 ppm! This was unexpected as we always sleep with the door slightly open and a ceiling fan on low.

A chart from my CO2 monitor that displays hourly air quality over four consecutive days. Each row is a day, and each square is an hour. Green squares mean normal CO2 levels and clean air; red squares mean high CO2 levels that can affect sleep and focus. Every day, the squares shift from green to red, showing air quality decreases during sleep.

Such high CO2 levels, as highlighted in Harvard University's research, can adversely impact sleep quality and cognitive functions.

After triple checking my code and monitoring the levels for several more nights, the trend was clear: CO2 concentrations consistently increased overnight, reaching levels beyond the recommended guidelines.

Armed with a few days of data, I presented my discoveries to Vanessa. Initially met with her characteristic skepticism (read: an eye-roll), she swiftly enabled the air cycling mode on our Nest thermostat. This function automatically activates the fan to circulate air, ensuring fresh air without the need to heat or cool.

The graph below shows how using the air cycling mode on our thermostat significantly improved CO2 levels in our bedroom. It proved to be much more effective than just keeping the door open and relying on a ceiling fan. What took me several nights to construct and analyze, Vanessa remedied in under a minute.

A chart from my CO2 monitor that displays hourly air quality over four consecutive days. Each row is a day, and each square is an hour. Green squares mean normal CO2 levels and clean air; red squares mean high CO2 levels that can affect sleep and focus. The chart shows that CO2 levels remain healthy after we turned on the Nest's air cycling feature.

Of course, opening a window is a simple method to improve indoor air quality and would likely reduce CO2 levels more effectively than the Nest's air cycling mode. However, I'm told that living in a city and having white curtains makes us hesitant to do so.

Nevertheless, this project highlights how a bit of curiosity and creativity can enhance the health and comfort of our living spaces.

Starting your own CO2 monitor project can be an exciting and rewarding endeavor. In the rest of this blog post, I'll help you get started. I've detailed my hardware setup and provided the client-side code. As mentioned, the backend code builds on my thermometer project, so please consult that for further details.

Hardware used

For this project, I bought:

  1. Adafruit ESP32-S3 Feather: A microcontroller board with Wi-Fi and Bluetooth capabilities, serving as the central processing unit of my project.
  2. Adafruit SCD41 sensor: A high-accuracy CO2 and temperature sensor.
  3. 3.7v 500mAh battery: A small and portable power source.
  4. STEMMA QT / Qwiic JST SH 4-pin cable: To connect the sensor to the board without soldering.
Client code

What I also love about Sensirion is that they have Arduino libraries for their sensors, including for the SCD4x series (https://github.com/Sensirion/arduino-i2c-scd4x). These can easily be installed through Adafruit IDE.

Installing the Sensirion SCD4x library via the Arduino IDE.

Once installed, incorporating it into your project is straightforward–simply include #include "SensirionI2CScd4x.h" in your code.

Below is the complete client code. It comes with very detailed code comments to make it easy to understand.

#include "SensirionI2CScd4x.h" #include "Adafruit_MAX1704X.h" #include "WiFiManager.h" #include "ArduinoJson.h" #include "HTTPClient.h" #include "Wire.h" // The Adafruit_SCD4x sensor is a CO2, temperature and humidity sensor with // an I2C interface. SensirionI2CScd4x scd4x; // The Adafruit ESP32-S3 Feather comes with a built-in MAX17048 LiPoly / LiIon // battery monitor. The MAX17048 provides accurate monitoring of the battery's // voltage. Utilizing the Adafruit library helps us not only obtain the raw // voltage data from the battery cell, but also converts this data into a more // intuitive battery percentage or charge level. We will pass on the battery // percentage to the web service endpoint, which can visualize it or use it to // send notifications when the battery needs recharging. Adafruit_MAX17048 maxlipo; // The setup() function is used to initialize the device's hardware and // communications. It's executed once at startup. Here, we begin serial // communication, initialize sensors, connect to Wi-Fi, and send initial // data. void setup() { Serial.begin(115200); // Wait for the serial connection to establish before proceeding further. // This is crucial for boards with native USB interfaces. Without this loop, // initial output sent to the serial monitor is lost. This code is not // needed when running on battery. // delay(5000); // Generates a unique device ID from a segment of the MAC address. // Since the MAC address is permanent and unchanged after reboots, // this guarantees the device ID remains consistent. To achieve a // compact ID, only a specific portion of the MAC address is used, // specifically the range between 0x10000 and 0xFFFFF. This range // translates to a hexadecimal string of a fixed 5-character length, // giving us roughly 1 million unique IDs. This approach balances // uniqueness with compactness. uint64_t chipid = ESP.getEfuseMac(); uint32_t deviceValue = ((uint32_t)(chipid >> 16) & 0x0FFFFF) | 0x10000; char device[6]; // 5 characters for the hex representation + the null terminator. sprintf(device, "%x", deviceValue); // Use '%x' for lowercase hex letters // Initialize the MAX17048 sensor. if (maxlipo.begin()) { Serial.println(F("MAX17048 battery monitor initialized.")); } else { Serial.println(F("Could not find MAX17048 battery monitor!")); return; } // Initialize the SHT4x sensor. scd4x.begin(Wire); uint16_t error; // Adjust the temperature sensor's offset to 1 degree to correct for deviations, // including sensor self-heating and environmental factors (e.g., sun exposure). // The factory default is 4 degrees. Customize this offset for your specific // environment to enhance the accuracy of temperature readings. error = scd4x.setTemperatureOffset(1.0); if (error) { Serial.print(F("Error trying to set temperature offset: ")); Serial.println(error); return; } // Initiate a one-time measurement of CO2 concentration, relative humidity, and // temperature. We use "single shot" mode, which means the sensor performs a // one-time measurement. This process takes approximately 5 seconds to complete. // After the measurement, the result is available for retrieval. error = scd4x.measureSingleShot(); if (error) { Serial.print(F("Error trying to put sensor in single shot mode: ")); Serial.println(error); return; } // Implement a delay of 1 second before initiating the next measurement. This // delay helps ensure the sensor has adequate time to prepare for the next // reading. This practice aligns with general sensor operation guidelines, // where a brief pause between measurements can help in achieving more accurate // and stable readings by allowing the sensor's internal components to stabilize. delay(1000); // According to the sensor's datasheet, we should ignore the first CO2 reading // after the sensor has been powered on or reset. The rationale behind this is // that the sensor's readings need one measurement to stabilize. Thus, we // perform a second "single shot" measurement, and use the results of this // second reading. error = scd4x.measureSingleShot(); if (error) { Serial.print(F("Error trying to put sensor in single shot mode: ")); Serial.println(error); return; } // Read the CO2, temperature and humidity values from the sensor. uint16_t co2 = 0; float temperature = 0.0f; float humidity = 0.0f; error = scd4x.readMeasurement(co2, temperature, humidity); if (error) { Serial.print(F("Error trying to read measurement: ")); Serial.println(error); return; } // Read the battery charge level and cap it at 100%. This step corrects any // readings above 100%, which seems to occur due to measurement anomalies or // calculation inaccuracies. This ensures the displayed or reported battery // level is credible. float batteryPercent = maxlipo.cellPercent(); batteryPercent = (batteryPercent > 100) ? 100 : batteryPercent; WiFiManager wifiManager; // Uncomment the following line to erase all saved WiFi credentials. // This can be useful for debugging or reconfiguration purposes. // wifiManager.resetSettings(); // This WiFi manager attempts to establish a WiFi connection using known // credentials, stored in RAM. If it fails, the device will switch to Access // Point mode, creating a network named "Temperature Monitor". In this mode, // connect to this network, navigate to the device's IP address (default IP // is 192.168.4.1) using a web browser, and a configuration portal will be // presented, allowing you to enter new WiFi credentials. Upon submission, // the device will reboot and try connecting to the specified network with // these new credentials. if (!wifiManager.autoConnect("CO2 Monitor")) { Serial.println(F("Failed to connect to WiFi ...")); // If the device fails to connect to WiFi, it will restart to try again. // This approach is useful for handling temporary network issues. However, // in scenarios where the network is persistently unavailable (e.g. router // down for more than an hour, consistently poor signal), the repeated // restarts and WiFi connection attempts can quickly drain the battery. ESP.restart(); // Mandatory delay to allow the restart process to initiate properly. delay(1000); return; } // Send collected data as JSON to the specified URL. sendJsonData("https://dri.es/sensors", device, co2, temperature, humidity, batteryPercent); // WiFi consumes significant power so turn it off when done. WiFi.disconnect(true); // Enter deep sleep for 10 minutes. The ESP32-S3's deep sleep mode minimizes // power consumption by powering down most components, except the RTC. This // mode is efficient for battery-powered projects where constant operation // isn't needed. When the device wakes up after the set period, it runs // setup() again, as the state isn't preserved. Serial.println(F("Going to sleep for 10 minutes ...")); ESP.deepSleep(10 * 60 * 1000000); // 10 mins * 60 secs/min * 1,000,000 μs/sec. } bool sendJsonData(const char* url, const char* device, float co2, float temperature, float humidity, float battery) { StaticJsonDocument<200> doc; // Round floating-point values to one decimal place for efficient data // transmission. This approach reduces the JSON payload size, which is // important for IoT applications running on battery. doc["device"] = device; doc["co2"] = String(co2, 0); doc["temperature"] = String(temperature, 1); doc["humidity"] = String(humidity, 1); doc["battery"] = String(battery, 0); // Serialize JSON to a string. String jsonData; serializeJson(doc, jsonData); // Initialize an HTTP client with the provided URL. HTTPClient httpClient; httpClient.begin(url); httpClient.addHeader("Content-Type", "application/json"); // Send a HTTP POST request. int httpCode = httpClient.POST(jsonData); // Close the HTTP connection. httpClient.end(); // Print debug information to the serial console. Serial.println("Sent '" + jsonData + "' to " + String(url) + ", return code " + httpCode); return (httpCode == 200); } void loop() { // The ESP32-S3 resets and runs setup() after waking up from deep sleep, // making this continuous loop unnecessary. }
Dries

Sydney Opera House using Drupal

3 weeks 2 days ago

Across its 50-year history, the Sydney Opera House has welcomed musicians, dancers, actors, playwrights, filmmakers, contemporary artists, and thinkers who have both challenged and defined the cultural scene. As a result, the Sydney Opera House draws millions of visitors from around the world each year.

Not only is the Sydney Opera House of incredible cultural importance, it's also an architectural masterpiece. Its unique design makes it one of the most iconic buildings in the world, and has earned it a place as a UNESCO World Heritage Site.

Last year, the Sydney Opera House chose to migrate its website to Drupal. Today, it is running Drupal 10. The decision by such a prestigious institution to relaunch their website on Drupal highlights Drupal's flexibility, security, and ability to manage complex websites.

A couple of weeks ago, during my visit to Australia, I met with the Drupal team at the Sydney Opera House. I was particularly impressed by the team's dedication to using Open Source to expand cultural access and their enthusiasm for collaborating with other arts and cultural organizations. Their focus on innovation, inclusivity, and collaboration perfectly aligns with the core values of Open Source and the Open Web. Drupal is such a great solution for them!

Dries

Building my own temperature and humidity monitor

1 month 2 weeks ago

Last fall, we toured the Champagne region in France, famous for its sparkling wines. We explored the ancient, underground cellars where Champagne undergoes its magical transformation from grape juice to sparkling wine. These cellars, often 30 meters deep and kilometers long, maintain a constant temperature of around 10-12°C, providing the perfect conditions for aging and storing Champagne.

25 meters underground in a champagne tunnel, which often stretches for miles/kilometers.

After sampling various Champagnes, we returned home with eight cases to store in our home's basement. However, unlike those deep cellars, our basement is just a few meters deep, prompting a simple question that sent me down a rabbit hole: how does our basement's temperature compare?

Rather than just buying a thermometer, I decided to build my own "temperature monitoring system" using open hardware and custom-built software. After all, who needs a simple solution when you can spend evenings tinkering with hardware, sensors, wires and writing your own software? Sometimes, more is more!

The basic idea is this: track the temperature and humidity of our basement every 15 minutes and send this information to a web service. This web service analyzes the data and alerts us if our basement becomes too cold or warm.

I launched this monitoring system around Christmas last year, so it's been running for nearly three months now. You can view the live temperature and historical data trends at https://dri.es/sensors. Yes, publishing our basement's temperature online is a bit quirky, but it's all in good fun.

A screenshot of my basement temperature dashboard.

So far, the temperature in our basement has been ideal for storing wine. However, I expect it will change during the summer months.

In the rest of this blog post, I'll share how I built the client that collects and sends the data, as well as the web service backend that processes and visualizes that data.

Hardware used

For this project, I bought:

  1. Adafruit ESP32-S3 Feather: A microcontroller board with Wi-Fi and Bluetooth capabilities, serving as the central processing unit of my project.
  2. Adafruit SHT4x sensor: A high-accuracy temperature and humidity sensor.
  3. 3.7v 500mAh battery: A small and portable power source.
  4. STEMMA QT / Qwiic JST SH 4-pin cable: To connect the sensor to the board without soldering.

The total hardware cost was $32.35 USD. I like Adafruit a lot, but it's worth noting that their products often come at a higher cost. You can find comparable hardware for as little as $10-15 elsewhere. Adafruit's premium cost is understandable, considering how much valuable content they create for the maker community.

An ESP32-S3 development board (middle) linked to a Sensirion SHT41 temperature and humidity sensor (left) and powered by a battery pack (right). Client code for Adafruit ESP32-S3 Feather

I developed the client code for the Adafruit ESP32-S3 Feather using the Arduino IDE, a widely used platform for developing and uploading code to Arduino-compatible boards.

The code measures temperature and humidity every 15 minutes, connects to WiFi, and sends this data to https://dri.es/sensors, my web service endpoint.

One of my goals was to create a system that could operate for a long time without needing to recharge the battery. The ESP32-S3 supports a "deep sleep" mode where it powers down almost all its functions, except for the clock and memory. By placing the ESP32-S3 into deep sleep mode between measurements, I was able to significantly reduce power.

Now that you understand the high-level design goals, including deep sleep mode, I'll share the complete client code below. It includes detailed code comments, making it self-explanatory.

#include "Adafruit_SHT4x.h" #include "Adafruit_MAX1704X.h" #include "WiFiManager.h" #include "ArduinoJson.h" #include "HTTPClient.h" // The Adafruit_SHT4x sensor is a high-precision, temperature and humidity // sensor with an I2C interface. Adafruit_SHT4x sht4 = Adafruit_SHT4x(); // The Adafruit ESP32-S3 Feather comes with a built-in MAX17048 LiPoly / LiIon // battery monitor. The MAX17048 provides accurate monitoring of the battery's // voltage. Utilizing the Adafruit library, not only helps us obtain the raw // voltage data from the battery cell, but also converts this data into a more // intuitive battery percentage or charge level. We will pass on the battery // percentage to the web service endpoint, which can visualize it or use it to // send notifications when the battery needs recharging. Adafruit_MAX17048 maxlipo; // The setup() function is used to initialize the device's hardware and // communications. It's executed once at startup. Here, we begin serial // communication, initialize sensors, connect to Wi-Fi, and send initial // data. void setup() { Serial.begin(115200); // Wait for the serial connection to establish before proceeding further. // This is crucial for boards with native USB interfaces. Without this loop, // initial output sent to the serial monitor is lost. This code is not // needed when running on battery. //delay(1000); // Generates a unique device ID from a segment of the MAC address. // Since the MAC address is permanent and unchanged after reboots, // this guarantees the device ID remains consistent. To achieve a // compact ID, only a specific portion of the MAC address is used, // specifically the range between 0x10000 and 0xFFFFF. This range // translates to a hexadecimal string of a fixed 5-character length, // giving us roughly 1 million unique IDs. This approach balances // uniqueness with compactness. uint64_t chipid = ESP.getEfuseMac(); uint32_t deviceValue = ((uint32_t)(chipid >> 16) & 0x0FFFFF) | 0x10000; char device[6]; // 5 characters for the hex representation + the null terminator. sprintf(device, "%x", deviceValue); // Use '%x' for lowercase hex letters // Initialize the SHT4x sensor: if (sht4.begin()) { Serial.println(F("SHT4 temperature and humidity sensor initialized.")); sht4.setPrecision(SHT4X_HIGH_PRECISION); sht4.setHeater(SHT4X_NO_HEATER); } else { Serial.println(F("Could not find SHT4 sensor.")); } // Initialize the MAX17048 sensor: if (maxlipo.begin()) { Serial.println(F("MAX17048 battery monitor initialized.")); } else { Serial.println(F("Could not find MAX17048 battery monitor!")); } // Insert a short delay to ensure the sensors are ready and their data is stable: delay(200); // Retrieve temperature and humidity data from SHT4 sensor: sensors_event_t humidity, temp; sht4.getEvent(&humidity, &temp); // Get the battery percentage and calibrate if it's over 100%: float batteryPercent = maxlipo.cellPercent(); batteryPercent = (batteryPercent > 100) ? 100 : batteryPercent; WiFiManager wifiManager; // Uncomment the following line to erase all saved WiFi credentials. // This can be useful for debugging or reconfiguration purposes. // wifiManager.resetSettings(); // This WiFi manager attempts to establish a WiFi connection using known // credentials, stored in RAM. If it fails, the device will switch to Access // Point mode, creating a network named "Temperature Monitor". In this mode, // connect to this network, navigate to the device's IP address (default IP // is 192.168.4.1) using a web browser, and a configuration portal will be // presented, allowing you to enter new WiFi credentials. Upon submission, // the device will reboot and try connecting to the specified network with // these new credentials. if (!wifiManager.autoConnect("Temperature Monitor")) { Serial.println(F("Failed to connect to WiFi ...")); // If the device fails to connect to WiFi, it will restart to try again. // This approach is useful for handling temporary network issues. However, // in scenarios where the network is persistently unavailable (e.g. router // down for more than an hour, consistently poor signal), the repeated // restarts and WiFi connection attempts can quickly drain the battery. ESP.restart(); // Mandatory delay to allow the restart process to initiate properly: delay(1000); } // Send collected data as JSON to the specified URL: sendJsonData("https://dri.es/sensors", device, temp.temperature, humidity.relative_humidity, batteryPercent); // WiFi consumes significant power so turn it off when done: WiFi.disconnect(true); // Enter deep sleep for 15 minutes. The ESP32-S3's deep sleep mode minimizes // power consumption by powering down most components, except the RTC. This // mode is efficient for battery-powered projects where constant operation // isn't needed. When the device wakes up after the set period, it runs // setup() again, as the state isn't preserved. Serial.println(F("Going to sleep for 15 minutes ...")); ESP.deepSleep(15 * 60 * 1000000); // 15 mins * 60 secs/min * 1,000,000 μs/sec. } bool sendJsonData(const char* url, const char* device, float temperature, float humidity, float battery) { StaticJsonDocument<200> doc; // Round floating-point values to one decimal place for efficient data // transmission. This approach reduces the JSON payload size, which is // important for IoT applications running on batteries. doc["device"] = device; doc["temperature"] = String(temperature, 1); doc["humidity"] = String(humidity, 1); doc["battery"] = String(battery, 1); // Serialize JSON to a string: String jsonData; serializeJson(doc, jsonData); // Initialize an HTTP client with the provided URL: HTTPClient httpClient; httpClient.begin(url); httpClient.addHeader("Content-Type", "application/json"); // Send a HTTP POST request: int httpCode = httpClient.POST(jsonData); // Close the HTTP connection: httpClient.end(); // Print debug information to the serial console: Serial.println("Sent '" + jsonData + "' to " + String(url) + ", return code " + httpCode); return (httpCode == 200); } void loop() { // The ESP32-S3 resets and runs setup() after waking up from deep sleep, // making this continuous loop unnecessary. } Further optimizing battery usage

When I launched my thermometer around Christmas 2023, the battery was at 88%. Today, it is at 52%. Some quick math suggests it's using approximately 12% of its battery per month. Given its current rate of usage, it needs recharging about every 8 months.

Connecting to the WiFi and sending data are by far the main power drains. To extend the battery life, I could send updates less frequently than every 15 minutes, only send them when there is a change in temperature (which is often unchanged or only different by 0.1°C), or send batches of data points together. Any of these methods would work for my needs, but I haven't implemented them yet.

Alternatively, I could hook the microcontroller up to a 5V power adapter, but where is the fun in that? It goes against the project's "more is more" principle.

Handling web service requests

With the client code running on the ESP32-S3 and sending sensor data to https://dri.es/sensors, the next step is to set up a web service endpoint to receive this incoming data.

As I use Drupal for my website, I implemented the web service endpoint in Drupal. Drupal uses Symfony, a popular PHP framework, for large parts of its architecture. This combination offers an easy but powerful way for implementing web services, similar to those found across other modern server-side web development frameworks like Laravel, Django, etc.

Here is what my Drupal routing configuration looks like:

sensors.sensor_data: path: '/sensors' methods: [POST] defaults: _controller: '\Drupal\sensors\Controller\SensorMonitorController::postSensorData' requirements: _access: 'TRUE'

The above configuration directs Drupal to send POST requests made to https://dri.es/sensors to the postSensorData() method of the SensorMonitorController class.

The implementation of this method handles request authentication, validates the JSON payload, and saves the data to a MariaDB database table. Pseudo-code:

public function postSensorData(Request $request) : JsonResponse { $content = $request->getContent(); $data = json_decode($content, TRUE); // Validate the JSON payload: … // Authenticate the request: … $device = DeviceFactory::getDevice($data['device']); if ($device) { $device->recordSensorEvent($data); } return new JsonResponse(['message' => 'Thank you!']); }

For testing your web service, you can use tools like cURL:

$ curl -X POST -H "Content-Type: application/json" -d '{"device":"0xdb123", "temperature":21.5, "humidity":42.5, "battery":90.0}' https://localhost/sensors

While cURL is great for quick tests, I use PHPUnit tests for automated testing in my CI/CD workflow. This ensures that everything keeps working, even when upgrading Drupal, Symfony, or other components of my stack.

Storing sensor data in a database

The primary purpose of $device->recordSensorEvent() in SensorMonitorController::postSensorData() is to store sensor data into a SQL database. So, let's delve into the database design.

My main design goals for the database backend were:

  1. Instead of storing every data point indefinitely, only keep the daily average, minimum, maximum, and the latest readings for each sensor type across all devices.
  2. Make it easy to add new devices and new sensors in the future. For instance, if I decide to add a CO2 sensor for our bedroom one day (a decision made in my head but not yet pitched to my better half), I want that to be easy.

To this end, I created the following MariaDB table:

CREATE TABLE sensor_data ( date DATE, device VARCHAR(255), sensor VARCHAR(255), avg_value DECIMAL(5,1), min_value DECIMAL(5,1), max_value DECIMAL(5,1), min_timestamp DATETIME, max_timestamp DATETIME, readings SMALLINT NOT NULL, UNIQUE KEY unique_stat (date, device, sensor) );

A brief explanation for each field:

  • date: The date for each sensor reading. It doesn't include a time component as we aggregate data on a daily basis.
  • device: The device ID of the device providing the sensor data, such as 'basement' or 'bedroom'.
  • sensor: The type of sensor, such as 'temperature', 'humidity' or 'co2'.
  • avg_value: The average value of the sensor readings for the day. Since individual readings are not stored, a rolling average is calculated and updated with each new reading using the formula: avg_value = avg_value + new_value - avg_value new_total_readings . This method can accumulate minor rounding errors, but simulations show these are negligible for this use case.
  • min_value and max_value: The daily minimum and maximum sensor readings.
  • min_timestamp and max_timestamp: The exact moments when the minimum and maximum values for that day were recorded.
  • readings: The number of readings (or measurements) taken throughout the day, which is used for calculating the rolling average.

In essence, the recordSensorEvent() method needs to determine if a record already exists for the current date. Depending on this determination, it will either insert a new record or update the existing one.

In Drupal this process is streamlined with the merge() function in Drupal's database layer. This function handles both inserting new data and updating existing data in one step.

private function updateDailySensorEvent(string $sensor, float $value): void { $timestamp = \Drupal::time()->getRequestTime(); $date = date('Y-m-d', $timestamp); $datetime = date('Y-m-d H:i:s', $timestamp); $connection = Database::getConnection(); $result = $connection->merge('sensor_data') ->keys([ 'device' => $this->id, 'sensor' => $sensor, 'date' => $date, ]) ->fields([ 'avg_value' => $value, 'min_value' => $value, 'max_value' => $value, 'min_timestamp' => $datetime, 'max_timestamp' => $datetime, 'readings' => 1, ]) ->expression('avg_value', 'avg_value + ((:new_value - avg_value) / (readings + 1))', [':new_value' => $value]) ->expression('min_value', 'LEAST(min_value, :value)', [':value' => $value]) ->expression('max_value', 'GREATEST(max_value, :value)', [':value' => $value]) ->expression('min_timestamp', 'IF(LEAST(min_value, :value) = :value, :timestamp, min_timestamp)', [':value' => $value, ':timestamp' => $datetime]) ->expression('max_timestamp', 'IF(GREATEST(max_value, :value) = :value, :timestamp, max_timestamp)', [':value' => $value, ':timestamp' => $datetime]) ->expression('readings', 'readings + 1') ->execute(); }

Here is what the query does:

  • It checks if a record for the current sensor and date exists.
  • If not, it creates a new record with the sensor data, including the initial average, minimum, maximum, and latest value readings, along with the timestamp for these values.
  • If a record does exist, it updates the record with the new sensor data, adjusting the average value, and updating minimum and maximum values and their timestamps if the new reading is a new minimum or maximum.
  • The function also increments the count of readings.

For those not using Drupal, similar functionality can be achieved with MariaDB's INSERT ... ON DUPLICATE KEY UPDATE command, which allows for the same conditional insert or update logic based on whether the specified unique key already exists in the table.

Here are example queries, extracted from MariaDB's General Query Log to help you get started:

INSERT INTO sensor_data (device, sensor, date, min_value, min_timestamp, max_value, max_timestamp, readings) VALUES ('0xdb123', 'temperature', '2024-01-01', 21, '2024-01-01 00:00:00', 21, '2024-01-01 00:00:00', 1); UPDATE sensor_data SET min_value = LEAST(min_value, 21), min_timestamp = IF(LEAST(min_value, 21) = 21, '2024-01-01 00:00:00', min_timestamp), max_value = GREATEST(max_value, 21), max_timestamp = IF(GREATEST(max_value, 21) = 21, '2024-01-01 00:00:00', max_timestamp), readings = readings + 1 WHERE device = '0xdb123' AND sensor = 'temperature' AND date = '2024-01-01'; Generating graphs

With the data securely stored in the database, the next step involved generating the graphs. To accomplish this, I wrote some custom PHP code that generates Scalable Vector Graphics (SVGs).

Given that is blog post is already quite long, I'll spare you the details. For now, those curious can use the 'View source' feature in their web browser to examine the SVGs on the thermometer page.

Conclusion

It's fun how a visit to the Champagne cellars in France sparked an unexpected project. Choosing to build a thermometer rather than buying one allowed me to dive back into an old passion for hardware and low-level software.

I also like taking control of my own data and software. It gives me a sense of control and creativity.

As Drupal's project lead, using Drupal for an Internet-of-Things (IoT) backend brought me unexpected joy. I just love the power and flexibility of open-source platforms like Drupal.

As a next step, I hope to design and 3D print a case for my thermometer, something I've never done before. And as mentioned, I'm also considering integrating additional sensors. Stay tuned for updates!

Dries

Drupal adventures in Japan and Australia

1 month 3 weeks ago

Next week, I'm traveling to Japan and Australia. I've been to both countries before and can't wait to return – they're among my favorite places in the world.

My goal is to connect with the local Drupal community in each country, discussing the future of Drupal, learning from each other, and collaborating.

I'll also be connecting with Acquia's customers and partners in both countries, sharing our vision, strategy and product roadmap. As part of that, I look forward to spending some time with the Acquia teams as well – about 20 employees in Japan and 35 in Australia.

I'll present at a Drupal event in Tokyo the evening of March 14th at Yahoo! Japan.

While in Australia, I'll be attending Drupal South, held at the Sydney Masonic Centre from March 20-22. I'm excited to deliver the opening keynote on the morning of March 20th, where I'll delve into Drupal's past, present, and future.

I look forward to being back in Australia and Japan, reconnecting with old friends and the local communities.

Dries
Checked
8 hours 59 minutes ago
Subscribe to Dri.es feed