From 4935699bd6308e9ee66e0d3dcbfdfc0ba1706aa6 Mon Sep 17 00:00:00 2001 From: Larry Bank Date: Thu, 23 Apr 2026 20:16:22 -0400 Subject: [PATCH] initial commit --- LICENSE | 204 +++++++++++ README.md | 4 + .../crowpanel_s3_lcd/crowpanel_s3_lcd.ino | 71 ++++ examples/hello_trmnl/display.cpp | 331 ++++++++++++++++++ examples/hello_trmnl/hello_trmnl.ino | 57 +++ examples/t_dongle_sensor/display.cpp | 29 ++ examples/t_dongle_sensor/t_dongle_sensor.ino | 51 +++ library.properties | 11 + src/trmnl_lib.cpp | 303 ++++++++++++++++ src/trmnl_lib.h | 95 +++++ 10 files changed, 1156 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/crowpanel_s3_lcd/crowpanel_s3_lcd.ino create mode 100644 examples/hello_trmnl/display.cpp create mode 100644 examples/hello_trmnl/hello_trmnl.ino create mode 100644 examples/t_dongle_sensor/display.cpp create mode 100644 examples/t_dongle_sensor/t_dongle_sensor.ino create mode 100644 library.properties create mode 100644 src/trmnl_lib.cpp create mode 100644 src/trmnl_lib.h diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..326e04a --- /dev/null +++ b/LICENSE @@ -0,0 +1,204 @@ +Copyright 2020 BitBank Software, Inc. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 BitBank Software, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..600e0b0 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# TRMNL library +A link library to easily add TRMNL functionality to any project.
+ + diff --git a/examples/crowpanel_s3_lcd/crowpanel_s3_lcd.ino b/examples/crowpanel_s3_lcd/crowpanel_s3_lcd.ino new file mode 100644 index 0000000..3651ba8 --- /dev/null +++ b/examples/crowpanel_s3_lcd/crowpanel_s3_lcd.ino @@ -0,0 +1,71 @@ +// +// TRMNL demo for the Crowpanel Advance ESP32-P4 5" 800x480 +// +#include +#include +//#define PNG_MAX_BUFFERED_PIXELS (801 * 4 * 2) +#include +PNG png; +BB_SPI_LCD lcd; +TRMNL trmnl; + +// Enter your WiFi credentials and TRMNL Device Key +const char *ssid = "your_ssid"; +const char *pw = "your_password"; +const char *szAPIKey = "your_api_key"; // N.B. Don't share this publicly +uint16_t *pTemp; + +int png_draw(PNGDRAW *pDraw) +{ +int iWidth = pDraw->iWidth; + + if (pDraw->y == 0) { + lcd.setAddrWindow(0, 0, lcd.width(), lcd.height()); + } + if (pDraw->y >= lcd.height()) { + return 0; // stop decoding if we'll go past the bottom + } + if (iWidth > lcd.width()) iWidth = lcd.width(); // crop image width to display size if it's larger + png.getLineAsRGB565(pDraw, pTemp, PNG_RGB565_BIG_ENDIAN, 0xffffff); + lcd.pushPixels(pTemp, iWidth); + return 1; +} /* png_draw() */ + +void displayImage(uint8_t *pImage, int iImageSize) +{ + int rc = png.openRAM(pImage, iImageSize, png_draw); + if (rc == PNG_SUCCESS) { + Serial.printf("PNG opened: %d x %d, %d-bpp\n", png.getWidth(), png.getHeight(), png.getBpp()); + pTemp = (uint16_t *)malloc(png.getWidth() * 2); // temporary RGB565 buffer + png.decode(NULL, 0); + free(pTemp); + } + png.close(); +} /* displayImage() */ + +void setup() { +int rc, iSize; +uint8_t *pImg; + + Serial.begin(115200); + delay(3000); + Serial.println("Starting TRMNL demo"); + lcd.begin(DISPLAY_ELECROW_S3_800x480); + lcd.fillScreen(TFT_BLACK); // clear to black and sets memory window for next pass + + trmnl.setDisplaySize(800, 480); // dynamic display size is not supported yet; for future use + if (trmnl.connectWiFi(ssid, pw)) { + rc = trmnl.getAPI(szAPIKey); // also reads and sends the sensor values if valid + if (rc == TRMNL_SUCCESS) { + rc = trmnl.getImage(&pImg, &iSize); + if (rc == TRMNL_SUCCESS) { + displayImage(pImg, iSize); + trmnl.freeImage(); + } + } + trmnl.disconnectWiFi(); + } // connected +} + +void loop() { +} diff --git a/examples/hello_trmnl/display.cpp b/examples/hello_trmnl/display.cpp new file mode 100644 index 0000000..432b80b --- /dev/null +++ b/examples/hello_trmnl/display.cpp @@ -0,0 +1,331 @@ +#include +#include +#define USE_EPAPER +#ifdef USE_EPAPER +#include +#include "../Fonts/Roboto_Black_16.h" +extern TRMNL trmnl; +BBEPAPER bbep; +#else +#include +BB_SPI_LCD lcd; +#endif // !BB_EPAPER +int iImageHeight; +PNG *png; +uint16_t *pTemp; + +// +// Draw callback (per scan line) from PNGdec +// +#ifdef USE_EPAPER +void ReduceBpp(int iDestBpp, int iPixelType, uint8_t *pPalette, uint8_t *pSrc, uint8_t *pDest, int w, int iSrcBpp) +{ + int g = 0, x, iDelta; + uint8_t *s, *d, *pPal, u8, count; + const uint8_t u8G2ToG8[4] = {0x00, 0x55, 0xaa, 0xff}; // 2-bit to 8-bit gray + + if (iPixelType == PNG_PIXEL_TRUECOLOR) iSrcBpp = 24; + else if (iPixelType == PNG_PIXEL_TRUECOLOR_ALPHA) iSrcBpp = 32; + iDelta = iSrcBpp/8; // bytes per pixel + count = 8; // bits in a byte + u8 = 0; // start with all black + d = pDest; + s = pSrc; + for (x=0; x>4) * 3]; + g = (pPal[0] + pPal[1]*2 + pPal[2])/4; + } else { + g = (s[0] & 0xf0) | (s[0] >> 4); + } + } + break; + case 2: // We need to handle this case for 2-bit images with (random) palettes + g = s[0] >> (6-((x & 3) * 2)); + if (iPixelType == PNG_PIXEL_INDEXED) { + pPal = &pPalette[(g & 3)*3]; + g = (pPal[0] + pPal[1]*2 + pPal[2])/4; + } else { + g = u8G2ToG8[g & 3]; + } + if ((x & 3) == 3) { + s++; + } + break; + } // switch on bpp + if (iDestBpp == 1) { + u8 |= (g >> 7); // B/W + } else if (iDestBpp == 2) { // generate 4 gray levels (2 bits) + u8 |= (3 ^ (g >> 6)); // 4 gray levels (inverted relative to 1-bit) + } else { // must be 4-bpp output + u8 |= (g >> 4); + } + count -= iDestBpp; + if (count == 0) { // byte is full, move on + *d++ = u8; + u8 = 0; + count = 8; + } + } // for x + if (count != 8) { // partial byte remaining + u8 <<= count; + *d++ = u8; + } +} /* ReduceBpp() */ +enum { + PNG_1_BIT = 0, + PNG_1_BIT_INVERTED, + PNG_2_BIT_0, + PNG_2_BIT_1, + PNG_2_BIT_BOTH, + PNG_2_BIT_INVERTED, +}; + +int png_draw(PNGDRAW *pDraw) +{ + int x; + uint8_t ucBppChanged = 0, ucInvert = 0; + uint8_t uc, ucMask, src, *s, *d, *pTemp = bbep.getCache(); // get some scratch memory (not from the stack) + int iPlane = *(int *)pDraw->pUser; + int iWidth; + + iWidth = pDraw->iWidth; + if (pDraw->y >= bbep.height()) return 0; // stop decoding if we'll go past the bottom + if (iWidth > bbep.width()) iWidth = bbep.width(); // crop image width to display size if it's larger + + if (pDraw->iPixelType == PNG_PIXEL_INDEXED || pDraw->iBpp > 2) { + if (pDraw->iBpp == 1) { // 1-bit output, just see which color is brighter + uint32_t u32Gray0, u32Gray1; + u32Gray0 = pDraw->pPalette[0] + (pDraw->pPalette[1]<<2) + pDraw->pPalette[2]; + u32Gray1 = pDraw->pPalette[3] + (pDraw->pPalette[4]<<2) + pDraw->pPalette[5]; + if (u32Gray0 < u32Gray1) { + ucInvert = 0xff; + } + } else { + // Reduce the source image to 1-bpp or 2-bpp + ReduceBpp((pDraw->pUser) ? 2:1, pDraw->iPixelType, pDraw->pPalette, pDraw->pPixels, pTemp, iWidth, pDraw->iBpp); + ucBppChanged = 1; + } + } else if (pDraw->iBpp == 2) { + ucInvert = 0xff; // 2-bit non-palette images need to be inverted colors for 4-gray mode + } + s = (ucBppChanged) ? pTemp : (uint8_t *)pDraw->pPixels; + d = pTemp; + if (iPlane == PNG_1_BIT || iPlane == PNG_1_BIT_INVERTED) { + // 1-bit output, decode the single plane and write it + if (iPlane == PNG_1_BIT_INVERTED) ucInvert = ~ucInvert; // to do PLANE_FALSE_DIFF + if (iPlane == PNG_1_BIT_INVERTED && (bbep.capabilities() & BBEP_3COLOR)) { // write the red plane as 0's for this case + memset(d, 0, iWidth/8); + } else { + for (x=0; x black + uc |= 1; // high bit of source pair + } + src <<= 2; + if ((x & 3) == 3) { // new input byte + src = *s++; + src ^= ucInvert; + } + if ((x & 7) == 7) { // new output byte + *d++ = uc; + } + } // for x + } else { // normal 0/1 split plane + ucMask = (iPlane == PNG_2_BIT_0) ? 0x40 : 0x80; // lower or upper source bit + for (x=0; xy == iImageHeight-1 && iImageHeight < bbep.height()) { + int i, w = (bbep.width() + 7)/8; + memset(pTemp, 0xff, w); + for (i=pDraw->y; iiWidth; + + if (pDraw->y == 0) { + lcd.setAddrWindow(0, 0, lcd.width(), lcd.height()); + } + if (pDraw->y >= lcd.height()) { + return 0; // stop decoding if we'll go past the bottom + } + if (iWidth > lcd.width()) iWidth = lcd.width(); // crop image width to display size if it's larger + png->getLineAsRGB565(pDraw, pTemp, PNG_RGB565_BIG_ENDIAN, 0xffffff); + lcd.pushPixels(pTemp, iWidth); + return 1; +} /* png_draw() */ +#endif // USE_EPAPER +// +// Display a PNG image on the given display +// crop it if it's too large +// +void displayImage(uint8_t *pImage, int iImageSize) +{ +int iPlane; + png = new PNG(); + int rc = png->openRAM(pImage, iImageSize, png_draw); + if (rc == PNG_SUCCESS) { + bbep.begin(EPD_XTEINK_X3); + iImageHeight = png->getHeight(); + Serial.printf("PNG opened: %d x %d, %d-bpp\n", png->getWidth(), png->getHeight(), png->getBpp()); +#ifdef USE_EPAPER + bbep.setAddrWindow(0, 0, bbep.width(), bbep.height()); + if (png->getBpp() == 1) { // 1-bit image (single plane) + png->close(); // use a different PNGDraw callback for color matching + bbep.startWrite(PLANE_0); // start writing image data to plane 0 + iPlane = PNG_1_BIT; + png->decode(&iPlane, 0); + } else { // 2-bpp (or greater, but reduced to 2-bpp) + bbep.setPanelType(EP368_792x528_4GRAY); + bbep.startWrite(PLANE_0); // start writing image data to plane 0 + iPlane = PNG_2_BIT_0; + png->decode(&iPlane, 0); // tell PNGDraw to use bits for plane 0 + png->close(); // start over for plane 1 + iPlane = PNG_2_BIT_1; + png->openRAM((uint8_t *)pImage, iImageSize, png_draw); + bbep.startWrite(PLANE_1); // start writing image data to plane 1 + png->decode(&iPlane, 0); // decode it again to get plane 1 data + } + bbep.refresh(REFRESH_FULL); + bbep.sleep(LIGHT_SLEEP); +#else // must be color LCD + pTemp = (uint16_t *)malloc(png->getWidth() * 2); // temporary RGB565 buffer + lcd.begin(DISPLAY_WS_AMOLED_18); // Waveshare ESP32-S3 AMOLED 1.8" 368x448 + lcd.fillScreen(TFT_BLACK); // clear to black and sets memory window for next pass + png->decode(NULL, 0); + free(pTemp); +#endif + } else { + Serial.println("Error opening the image!"); + } + png->close(); + free(png); +} /* displayImage() */ + +void displaySensorValues(void) +{ + time_t now; + struct tm *thetime; +#ifdef USE_EPAPER + bbep.begin(EPD_WAVESHARE_154); // pre-configured for the Waveshare ESP32-S3 1.54" e-Paper + bbep.allocBuffer(); + bbep.fillScreen(BBEP_WHITE); + time(&now); + thetime = gmtime(&now); + bbep.setTextColor(BBEP_BLACK, BBEP_WHITE); + bbep.setFont(Roboto_Black_16); + bbep.setCursor(0, 24); // baseline, not top of font + bbep.print("TRMNL"); + bbep.setCursor(0,50); + bbep.print("Sensor node"); + bbep.setFont(FONT_12x16); + bbep.setCursor(0, 64); + bbep.printf("Last: %02d:%02d UTC\n", thetime->tm_hour, thetime->tm_min); + now += trmnl.getSleepTime(); + thetime = gmtime(&now); + bbep.printf("Next: %02d:%02d UTC\n", thetime->tm_hour, thetime->tm_min); + bbep.printf("Temp: %.1f C\n", trmnl.getTemperature()); + bbep.printf("Hum: %d %%\n", trmnl.getHumidity()); + if (trmnl.getCo2()) { + bbep.printf("CO2: %d ppm\n", trmnl.getCo2()); + } + bbep.writePlane(); + bbep.refresh(REFRESH_FAST); + bbep.sleep(LIGHT_SLEEP); +#else // LCD + lcd.begin(DISPLAY_M5STACK_ATOMS3); // pre-configured for the M5Stack AtomS3 + lcd.fillScreen(TFT_BLACK); + time(&now); + thetime = gmtime(&now); + lcd.setTextColor(TFT_GREEN, TFT_BLACK) + lcd.setCursor(0,0); + lcd.setFont(FONT_12x16); + lcd.println("TRMNL\nSensor node"); + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + lcd.printf("L: %02d:%02d UTC\n", thetime->tm_hour, thetime->tm_min); + now += trmnl.getSleepTime(); + thetime = gmtime(&now); + lcd.printf("N: %02d:%02d UTC\n", thetime->tm_hour, thetime->tm_min); + if (trmnl.temperature()) { + lcd.printf("Temp: %.1f C\n", trmnl.temperature()); + } + if (trmnl.humidity()) { + lcd.printf("Hum: %d %%\n", trmnl.humidity()); + } + if (trmnl.co2()) { + lcd.printf("CO2: %d ppm\n", trmnl.co2()); + } +#endif +} /* displaySensorValues() */ \ No newline at end of file diff --git a/examples/hello_trmnl/hello_trmnl.ino b/examples/hello_trmnl/hello_trmnl.ino new file mode 100644 index 0000000..b9ecd82 --- /dev/null +++ b/examples/hello_trmnl/hello_trmnl.ino @@ -0,0 +1,57 @@ +// +// A minimal Arduino example to show how to interface +// your own code to the TRMNL back-end +// written by Larry Bank (bitbank@pobox.com) +// March 25, 2025 +// +#include "trmnl_lib.h" +#include "esp_task_wdt.h" +void displayImage(uint8_t *pImage, int iImageSize); +void displaySensorValues(void); +TRMNL trmnl; + +// Enter your WiFi credentials and TRMNL Device Key +const char *ssid = "your_ssid"; +const char *pw = "your_password"; +const char *szAPIKey = "your_api_key"; // N.B. Don't share this publicly + +void setup() +{ +int rc; + uint8_t *pImage; + int iImageSize; + + Serial.begin(115200); + delay(3000); +// esp_task_wdt_config_t wdt_config = {30, 3, false}; +// esp_task_wdt_init(&wdt_config); // disable watchdog timer + + Serial.println("Starting TRMNL demo"); + trmnl.setDisplaySize(800, 480); // dynamic display size is not supported yet; for future use + trmnl.setSensorBus(2, 1); // M5Stack AtomS3 GROVE I2C +// trmnl.setSensorBus(47, 48); // Waveshare ESP32-S3 ePaper 1.54 internal I2C temp/humidity sensor + if (trmnl.connectWiFi(ssid, pw)) { + rc = trmnl.getAPI(szAPIKey); // also reads and sends the sensor values if valid + if (rc == TRMNL_SUCCESS) { +// displaySensorValues(); // instead of showing the TRMNL image + rc = trmnl.getImage(&pImage, &iImageSize); + if (rc == TRMNL_SUCCESS) { + displayImage(pImage, iImageSize); + trmnl.freeImage(); + } + } + trmnl.disconnectWiFi(); + } + // Special setup for the Xteink X3 to keep the battery on, but sleep with the lowest power + pinMode(13, OUTPUT); + digitalWrite(13, HIGH); + gpio_hold_en(GPIO_NUM_13); // MOSFET enabling the battery power + gpio_hold_en(GPIO_NUM_5); // hold EPD reset high in deep sleep + gpio_deep_sleep_hold_en(); // Needed to keep the battery power enabled during RTC sleep + + trmnl.sleep(); +} /* setup() */ + +void loop() +{ +} /* loop() */ diff --git a/examples/t_dongle_sensor/display.cpp b/examples/t_dongle_sensor/display.cpp new file mode 100644 index 0000000..611b8d0 --- /dev/null +++ b/examples/t_dongle_sensor/display.cpp @@ -0,0 +1,29 @@ +#include +extern TRMNL trmnl; +#include +extern BB_SPI_LCD lcd; + +void displaySensorValues(void) +{ + time_t now; + struct tm *thetime; + time(&now); + thetime = gmtime(&now); + lcd.setCursor(0,16); + lcd.setFont(FONT_12x16); + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + lcd.printf("%02d:%02d UTC\n", thetime->tm_hour, thetime->tm_min); +// now += trmnl.getSleepTime(); +// thetime = gmtime(&now); +// lcd.printf("N: %02d:%02d UTC\n", thetime->tm_hour, thetime->tm_min); + lcd.setTextColor(TFT_MAGENTA, TFT_BLACK); + if (trmnl.getCo2()) { + lcd.printf("CO2: %d ppm\n", trmnl.getCo2()); + } + if (trmnl.getTemperature()) { + lcd.printf("Temp: %.1f C\n", trmnl.getTemperature()); + } + if (trmnl.getHumidity()) { + lcd.printf("Hum: %d %%\n", trmnl.getHumidity()); + } +} /* displaySensorValues() */ \ No newline at end of file diff --git a/examples/t_dongle_sensor/t_dongle_sensor.ino b/examples/t_dongle_sensor/t_dongle_sensor.ino new file mode 100644 index 0000000..1ba08bf --- /dev/null +++ b/examples/t_dongle_sensor/t_dongle_sensor.ino @@ -0,0 +1,51 @@ +// +// Example sketch for the TRMNL library +// written by Larry Bank +// April 23, 2026 +// +// This sketch shows how to use the LilyGo T-Dongle C5 as a TRMNL sensor node +// What this means is that an I2C sensor attached to the T-Dongle will push +// Sensor readings to your TRMNL account for use in your plugins. The values +// will also be displayed on the color LCD of the T-Dongle +// +#include +#include +BB_SPI_LCD lcd; +TRMNL trmnl; +void displaySensorValues(void); + +// Enter your WiFi credentials and TRMNL Device Key +const char *ssid = "your_ssid"; +const char *pw = "your_password"; +const char *szAPIKey = "your_api_key"; // N.B. Don't share this publicly + +void setup() +{ + Serial.begin(115200); + delay(3000); + Serial.println("Starting TRMNL demo"); + + lcd.begin(DISPLAY_T_DONGLE_C5); // pre-configured for the LilyGo T-Dongle C5 + lcd.fillScreen(TFT_BLACK); + lcd.setTextColor(TFT_GREEN, TFT_BLACK); + lcd.setCursor(0,0); + lcd.setFont(FONT_12x16); + lcd.println("TRMNL Sensor"); + + trmnl.setSensorBus(11, 12); // T-Dongle C5 QWIIC I2C Bus +} + +void loop() +{ + int rc; + + if (trmnl.connectWiFi(ssid, pw)) { + rc = trmnl.getAPI(szAPIKey); // also reads and sends the sensor values if valid + if (rc == TRMNL_SUCCESS) { + displaySensorValues(); // instead of showing the TRMNL image + } + trmnl.disconnectWiFi(); + } + delay(15 * 60 * 1000); // push new values every 15 minutes +} + diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..3962d40 --- /dev/null +++ b/library.properties @@ -0,0 +1,11 @@ +name=trmnl_lib +version=1.0.0 +author=Larry Bank +maintainer=Larry Bank +sentence=Easily integrate TRMNL functionality into your project. +paragraph=This library contains the core functionality of TRMNL and allows you to make image requests and display them in your own projects. +category=Data Processing +url=https://github.com/bitbank2/trmnl_lib/ +architectures=* +includes=trmnl_lib.h + diff --git a/src/trmnl_lib.cpp b/src/trmnl_lib.cpp new file mode 100644 index 0000000..f6316fb --- /dev/null +++ b/src/trmnl_lib.cpp @@ -0,0 +1,303 @@ +// +// TRMNL library +// Written by Larry Bank +// project started March 25, 2026 +// +// SPDX-FileCopyrightText: 2026 TRMNL LLC +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//=========================================================================== +// +#include "trmnl_lib.h" +#include +#include +#include +#include +#include "esp_sntp.h" +#include +#include +SCD41 scd41; +BBTemp bbt; +static HTTPClient https; +// 10 seconds WIFI connection timeout +#define TIMEOUT 20 +const char *szDevices[] = {"None", "AHT20", "BMP180", "BME280", "BMP388", "SHT3X", "HDC1080", "HTS221", "MCP9808","BME68x","SHTC3"}; +const char *szMakers[] = {"None", "ASAIR", "Bosch", "Bosch", "Bosch", "Sensirion", "TI", "STMicro","MicroChip","Bosch","Sensirion"}; +// +// Initialize the I2C bus and find+start any sensors that are found +// +void TRMNL::setSensorBus(uint8_t sda, uint8_t scl) +{ + _bCO2 = false; + _bTimeSync = true; // time stamp needs to have the correct time + _iSensorType = -1; // assume no supported sensors + + // check if there is a SCD41 or supported temperature sensor attached + if (bbt.init(sda, scl) == BBT_SUCCESS) { + _iSensorType = bbt.type(); + Serial.printf("%s [%d]: supported sensor found! (%d)\r\n", __FILE__, __LINE__, _iSensorType); + bbt.start(); // start the sensor + } + if (scd41.init(sda, scl) == SCD41_SUCCESS) { + _bCO2 = true; + Serial.printf("%s [%d]: SCD41 sensor found!\r\n", __FILE__, __LINE__); + scd41.wakeup(); + // The SCD41 needs to be re-initialized after big Vcc variations from the last wakeup + // put it in a 'confused' state. If we don't re-initialize it, it won't generate more samples + scd41.sendCMD(SCD41_CMD_REINIT); + vTaskDelay(3); // allow time to reinitialize + scd41.triggerSample(); // trigger a 'one-shot' sample that takes about 5 seconds to complete + } + if (!_bCO2 && _iSensorType < 0) { + Serial.printf("%s [%d]: No sensor found on I2C bus %d/%d\r\n", __FILE__, __LINE__, sda, scl); + } + _lSensorTime = millis(); // mark the time when we triggered sensor samples +} /* setSensorBus() */ + +bool TRMNL::connectWiFi(const char *ssid, const char *pw) +{ + int iTimeout = 0; + + Serial.print("Connecting to wifi"); + + WiFi.begin(ssid, pw); + while (WiFi.status() != WL_CONNECTED && WiFi.status() != WL_CONNECT_FAILED && iTimeout < TIMEOUT) { + delay(500); // allow up to 10 seconds to connect + iTimeout++; + Serial.print("."); + } + if (iTimeout == TIMEOUT || WiFi.status() != WL_CONNECTED) { + Serial.println("\nConnection timed out!"); + } else { + Serial.println("\nConnected!"); + return true; + } + return false; // failed + +} /* connectWiFi() */ + +bool TRMNL::setClock() +{ + bool sync_status = false; + struct tm timeinfo; + time_t now; + int iDeltaTime; + Preferences prefs; + + prefs.begin("data"); + uint32_t u32Epoch = prefs.getUInt("last_sync", 0); // Get the last time sync time + time(&now); + iDeltaTime = (uint32_t)now - u32Epoch; // Number of seconds since the last sync + Serial.printf("%s [%d]: epoch time: %lu iDelta: %d\r\n", __FILE__, __LINE__, (uint32_t)now, iDeltaTime); + if (u32Epoch != 0 && iDeltaTime > 0 && iDeltaTime < 24*60*60) { // Less than 24h, no need to sync the time + Serial.printf("%s [%d]: Skipping time sync\r\n", __FILE__, __LINE__); + prefs.end(); + return true; + } + String ntp = prefs.getString("ntp_server", "time.google.com"); + + Serial.printf("%s [%d]: Using NTP: %s, fallback: time.cloudflare.com\r\n", __FILE__, __LINE__, ntp.c_str()); + configTime(0, 0, ntp.c_str(), "time.cloudflare.com"); + + for (int i = 0; i < SNTP_MAX_SERVERS; i++) { + const char *srv = esp_sntp_getservername(i); + if (srv && strlen(srv) > 0) { + Serial.printf("%s [%d]: SNTP server[%d]: %s\r\n", __FILE__, __LINE__, i, srv); + } + } + + Serial.printf("%s [%d]: Time synchronization...\r\n", __FILE__, __LINE__); + + // Wait for time to be set + if (getLocalTime(&timeinfo)) { + sync_status = true; + Serial.printf("%s [%d]: Time synchronization succeed!\r\n", __FILE__, __LINE__); + prefs.putUInt("last_sync", (uint32_t)now); // save epoch time of last sync + } else { + Serial.printf("%s [%d]: Time synchronization failed...\r\n", __FILE__, __LINE__); + } + + Serial.printf("%s [%d]: Current time - %s\r\n", __FILE__, __LINE__, asctime(&timeinfo)); + + prefs.end(); + return sync_status; +} /* setClock() */ + +void TRMNL::getSensorSamples() +{ + long lSleepTime; + lSleepTime = (_bCO2) ? 5000 : 1000; // sleep for 5 or 1 seconds depending on the sensor type + lSleepTime -= (millis() - _lSensorTime); // how much time passed since we triggered samples? + if (lSleepTime > 0) { + delay(lSleepTime); + } + if (_bCO2 && scd41.getSample() == SCD41_SUCCESS) { + time((time_t *)&_u32SensorEpoch); // get the UTC epoch time that the sample was captured + _iCO2 = scd41.co2(); + _iTemperature = scd41.temperature(); + _iHumidity = scd41.humidity(); + } + if (_iSensorType >= 0) { + BBT_SAMPLE bbts; + if (bbt.getSample(&bbts) == BBT_SUCCESS) { + uint32_t u32Caps = bbt.caps(); + time((time_t *)&_u32SensorEpoch); // get the UTC epoch time that the same was captured + if (u32Caps & BBT_CAP_TEMPERATURE) { + _iTemperature = bbts.temperature; + } + if (u32Caps & BBT_CAP_HUMIDITY) { + _iHumidity = bbts.humidity; + } + if (u32Caps & BBT_CAP_PRESSURE) { + _iPressure = bbts.pressure; + } + Serial.printf("%s [%d]: Got bb_temperature sample: Temp = %d.%dC\r\n", __FILE__, __LINE__, _iTemperature/10, _iTemperature % 10); + } else { + Serial.printf("%s [%d]: bb_temperature sample failed\r\n", __FILE__, __LINE__); + } + bbt.stop(); // turn off the sensor to conserve power + } +} /* getSensorSamples() */ + +int TRMNL::getAPI(const char *szAPIKey, const char *szURL, float fVoltage) +{ +int rc = TRMNL_ERROR; +bool bTimeValid = false; + + if (_bTimeSync) { + bTimeValid = setClock(); // Get the current time if not already set + } + https.begin(szURL); + https.setTimeout(15000); + https.setConnectTimeout(15000); + if ((_bCO2 || _iSensorType >= 0) && bTimeValid) { // Add HTTP headers for the sensor samples + char *szTemp, szPart[128]; + // Sensors are detected and active, get the latest sample value(s) + uint32_t u32SampleTime; + time((time_t *)&u32SampleTime); // get the UTC epoch time that the samples were captured + getSensorSamples(); + szTemp = (char *)malloc(1024); // make sure we have enough space, but don't use the stack because it's small + if (_iCO2 != 0) { // valid data from SCD4x for CO2, Temperature and Humidity + // create the multi-value string to pass as a HTTP header + sprintf(szTemp, "make=Sensirion;model=SCD41;kind=carbon_dioxide;value=%d;unit=parts_per_million;created_at=%lu,make=Sensirion;model=SCD41;kind=temperature;value=%f;unit=celsius;created_at=%lu,make=Sensirion;model=SCD41;kind=humidity;value=%d;unit=percent;created_at=%lu", _iCO2, u32SampleTime, (float)_iTemperature / 10.0f, u32SampleTime, _iHumidity, u32SampleTime); + Serial.printf("%s [%d] Adding SCD41 data to api request: CO2: %d, Temp: %d.%dC, Humidity: %d%%", __FILE__, __LINE__, _iCO2, _iTemperature/10, _iTemperature % 10, _iHumidity); + } + if (_iSensorType >= 0) { // we have data from another bb_temperature supported sensor too; add it + if (_iCO2 != 0) { + strcat(szTemp, ","); // separate from CO2 data + } else { + szTemp[0] = 0; + } + Serial.printf("%s [%d] Adding bb_temperature data to api request: pressure: %d, Temp: %d.%dC, Humidity: %d%%", __FILE__, __LINE__, _iPressure, _iTemperature/10, _iTemperature % 10, _iHumidity); + sprintf(szPart, "make=%s;model=%s;kind=temperature;value=%f;unit=celsius;created_at=%lu",szMakers[_iSensorType], szDevices[_iSensorType], (float)_iTemperature / 10.0f, u32SampleTime); + strcat(szTemp, szPart); + if (_iHumidity > 0) { // add humidity + sprintf(szPart, ",make=%s;model=%s;kind=humidity;value=%d;unit=percent;created_at=%lu",szMakers[_iSensorType], szDevices[_iSensorType], _iHumidity, u32SampleTime); + strcat(szTemp, szPart); + } + if (_iPressure > 0) { + sprintf(szPart, ",make=%s;model=%s;kind=pressure;value=%d;unit=hectopascal;created_at=%lu",szMakers[_iSensorType], szDevices[_iSensorType], _iPressure, u32SampleTime); + strcat(szTemp, szPart); + } + } + if (_iCO2 != 0 || _iSensorType >= 0) { + https.addHeader("SENSORS", szTemp); + } else { + Serial.printf("%s [%d] Sensor data not available", __FILE__, __LINE__); + } + free(szTemp); + } // add sensor info to HTTP headers + // add the HTTP headers + // https.addHeader("ID", inputs.macAddress); + https.addHeader("Content-Type", "application/json"); + https.addHeader("Access-Token", szAPIKey); +// https.addHeader("Refresh-Rate", String(inputs.refreshRate)); + https.addHeader("Battery-Voltage", String(fVoltage)); +// https.addHeader("FW-Version", inputs.firmwareVersion); + https.addHeader("Model", "byod"); + https.addHeader("RSSI", String(WiFi.RSSI())); + https.addHeader("Width", String(_iWidth)); + https.addHeader("Height", String(_iHeight)); + _httpCode = https.GET(); + // httpCode will be negative on error + Serial.printf("https GET returned: %d\n", _httpCode); + if (_httpCode >= 200 && _httpCode < 300) { // success, get payload + String payload = https.getString(); + size_t size = https.getSize(); + Serial.printf("Content size: %d\n", size); + Serial.printf("Payload: %s\n", payload.c_str()); + JsonDocument doc; + DeserializationError error = deserializeJson(doc, payload); + _status = doc["status"]; + _image_url = doc["image_url"] | ""; + _refresh_rate = doc["refresh_rate"]; + rc = TRMNL_SUCCESS; + } else { + _refresh_rate = 900; // default to 15 minutes + _status = API_STATUS_INVALID; + } + https.end(); + return rc; +} /* getAPI() */ + +int TRMNL::getImage(uint8_t **pBuffer, int *pSize) +{ + int rc = TRMNL_ERROR; + + Serial.println("TRMNL getImage()"); + + if (_status == API_STATUS_OK && _image_url.length() > 0) { + https.begin(_image_url); + https.setTimeout(15000); + https.setConnectTimeout(15000); + _httpCode = https.GET(); + Serial.printf("%s [%d]: [HTTPS] GET... code: %d\r\n", __FILE__, __LINE__, _httpCode); + Serial.printf("%s [%d]: RSSI: %d\r\n", __FILE__, __LINE__, WiFi.RSSI()); + if (_httpCode == HTTP_CODE_OK) { + Serial.printf("%s [%d]: Content size: %d\r\n", __FILE__, __LINE__, https.getSize()); + String payload = https.getString(); + *pSize = payload.length(); + if (*pSize) { + Serial.println("Got the image!"); + *pBuffer = _pImage = (uint8_t *)malloc(*pSize); + memcpy(*pBuffer, payload.c_str(), *pSize); + rc = TRMNL_SUCCESS; + } + } + https.end(); + } + return rc; +} /* getImage() */ + +void TRMNL::disconnectWiFi(void) +{ + Serial.println("TRMNL disconnectWiFi()"); + if (WiFi.status() == WL_CONNECTED) { + WiFi.disconnect(true, true, 500); + } +} /* disconnectWiFi() */ + +void TRMNL::sleep() +{ + Serial.println("TRMNL sleep()"); + esp_sleep_enable_timer_wakeup((uint64_t)_refresh_rate * 1000000L); + // If the user defined a button to wake us up (besides the timer) + if (_wake_gpio != 255) { +#if CONFIG_IDF_TARGET_ESP32 + esp_sleep_enable_ext1_wakeup(1 << _wake_gpio, ESP_EXT1_WAKEUP_ALL_LOW); +#elif CONFIG_IDF_TARGET_ESP32C3 + esp_deep_sleep_enable_gpio_wakeup(1 << _wake_gpio, ESP_GPIO_WAKEUP_GPIO_LOW); +#elif CONFIG_IDF_TARGET_ESP32S3 + esp_sleep_enable_ext0_wakeup((gpio_num_t)_wake_gpio, 0); +#endif + } + esp_deep_sleep_start(); +} /* sleep() */ diff --git a/src/trmnl_lib.h b/src/trmnl_lib.h new file mode 100644 index 0000000..041f00f --- /dev/null +++ b/src/trmnl_lib.h @@ -0,0 +1,95 @@ +// +// TRMNL library +// Written by Larry Bank +// project started March 25, 2026 +// +// SPDX-FileCopyrightText: 2026 TRMNL LLC +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//=========================================================================== +// +#ifndef __TRMNL_LIB__ +#define __TRMNL_LIB__ + +#ifdef __LINUX__ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#else // !LINUX + +#ifdef ARDUINO +#include +#endif // ARDUINO +#endif // !__LINUX__ + +#define API_STATUS_OK 0 +#define API_STATUS_NOT_REGISTERED 202 +#define API_STATUS_RESET 500 +#define API_STATUS_INVALID 999 + +enum { + TRMNL_SUCCESS = 0, + TRMNL_ERROR +}; + +class TRMNL +{ + public: + TRMNL() {_iTemperature = _iHumidity = _iCO2 = _iPressure = 0;} + ~TRMNL() {} + + void setSensorBus(uint8_t sda, uint8_t scl); + void setDisplaySize(int w, int h) {_iWidth = w; _iHeight = h;} + bool connectWiFi(const char *ssid, const char *pw); + int getAPI(const char *szAPIKey, const char *szURL = "https://trmnl.app/api/display", float fVoltage = 4.2f); + uint64_t getAPIStatus() {return _status;} + void setWakeButton(uint8_t u8Wake) {_wake_gpio = u8Wake;} + int getImage(uint8_t **pu8Buffer, int *piSize); + void disconnectWiFi(void); + void freeImage() {free(_pImage); _pImage = nullptr;} + int getHTTPCode() {return _httpCode;} + void sleep(); + float getTemperature() {return (float)_iTemperature / 10.0f;} + int getPressure() {return _iPressure;} + int getHumidity() {return _iHumidity;} + int getCo2() {return _iCO2;} + int getSleepTime() {return _refresh_rate;} + void setTimeSync(bool bSync) {_bTimeSync = bSync;} +protected: + bool setClock(); + void getSensorSamples(); + + private: + bool _bCO2, _bTimeSync = false; + int _iSensorType; + long _lSensorTime; + uint32_t _u32SensorEpoch; + int _iWidth = 800; // default to OG size + int _iHeight = 480; + int _iHumidity, _iTemperature, _iCO2, _iPressure, _httpCode; + String _image_url; + uint8_t *_pImage; + uint64_t _status = API_STATUS_INVALID; + uint64_t _refresh_rate = 30; // default to 30 seconds + uint8_t _wake_gpio = 255; +}; + +#endif // __TRMNL_LIB__