├── __gmsl ├── display.jpg ├── fonts └── ttf │ ├── Roboto-Bold.ttf │ └── Roboto-Regular.ttf ├── .gitignore ├── params.h ├── gfxfont.h ├── icons └── svgs │ ├── clear-night.svg │ ├── partly-cloudy-night.svg │ ├── cloudy.svg │ ├── fog.svg │ ├── wind.svg │ ├── rain.svg │ ├── clear-day.svg │ ├── snow.svg │ ├── partly-cloudy-day.svg │ └── sleet.svg ├── README.md ├── Makefile ├── gmsl ├── fontconvert.c ├── LICENSE └── eink_weather.ino /__gmsl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamc/eink_weather/HEAD/__gmsl -------------------------------------------------------------------------------- /display.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamc/eink_weather/HEAD/display.jpg -------------------------------------------------------------------------------- /fonts/ttf/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamc/eink_weather/HEAD/fonts/ttf/Roboto-Bold.ttf -------------------------------------------------------------------------------- /fonts/ttf/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgrahamc/eink_weather/HEAD/fonts/ttf/Roboto-Regular.ttf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | fontconvert 3 | fonts/gfx 4 | icons/pngs 5 | params.h 6 | 7 | # Prerequisites 8 | *.d 9 | 10 | # Compiled Object files 11 | *.slo 12 | *.lo 13 | *.o 14 | *.obj 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Compiled Dynamic libraries 21 | *.so 22 | *.dylib 23 | *.dll 24 | 25 | # Fortran module files 26 | *.mod 27 | *.smod 28 | 29 | # Compiled Static libraries 30 | *.lai 31 | *.la 32 | *.a 33 | *.lib 34 | 35 | # Executables 36 | *.exe 37 | *.out 38 | *.app 39 | -------------------------------------------------------------------------------- /params.h: -------------------------------------------------------------------------------- 1 | // WiFi networks (SSID (network name) and password) 2 | 3 | const int wifi_count = 1; 4 | const char *wifi_networks[1] = {"TODO"}; 5 | const char *wifi_passwords[1] = {"TODO"}; 6 | 7 | // When to take up to check the weather forecast. This is an 8 | // array of the minutes within an hour when the display should 9 | // reload and update. For example, {0, 15, 30, 45} would update 10 | // every 15 minutes starting at the top of the hour. {0, 30} 11 | // would update at hh:00 and hh:30. These need to be in ascending 12 | // order. 13 | 14 | #define UPDATE_TIMES 2 15 | uint8_t update_times[UPDATE_TIMES] = {0, 30}; 16 | 17 | // Pirate Weather API URL for a forecast. Here's where 18 | // you put your API key, the lat/long you want weather 19 | // for. 20 | 21 | const char *api_key = "TODO"; 22 | const char *lat = "TODO"; 23 | const char *lon = "TODO"; 24 | 25 | // The title to show at the top of the display 26 | 27 | const char *title = "TODO"; 28 | 29 | // Units for display, possible values: ca, uk, us, si. 30 | 31 | const char *units = "si"; 32 | -------------------------------------------------------------------------------- /gfxfont.h: -------------------------------------------------------------------------------- 1 | // Font structures for newer Adafruit_GFX (1.1 and later). 2 | // Example fonts are included in 'Fonts' directory. 3 | // To use a font in your Arduino sketch, #include the corresponding .h 4 | // file and pass address of GFXfont struct to setFont(). Pass NULL to 5 | // revert to 'classic' fixed-space bitmap font. 6 | 7 | #ifndef _GFXFONT_H_ 8 | #define _GFXFONT_H_ 9 | 10 | /// Font data stored PER GLYPH 11 | typedef struct { 12 | uint16_t bitmapOffset; ///< Pointer into GFXfont->bitmap 13 | uint8_t width; ///< Bitmap dimensions in pixels 14 | uint8_t height; ///< Bitmap dimensions in pixels 15 | uint8_t xAdvance; ///< Distance to advance cursor (x axis) 16 | int8_t xOffset; ///< X dist from cursor pos to UL corner 17 | int8_t yOffset; ///< Y dist from cursor pos to UL corner 18 | } GFXglyph; 19 | 20 | /// Data stored for FONT AS A WHOLE 21 | typedef struct { 22 | uint8_t *bitmap; ///< Glyph bitmaps, concatenated 23 | GFXglyph *glyph; ///< Glyph array 24 | uint16_t first; ///< ASCII extents (first char) 25 | uint16_t last; ///< ASCII extents (last char) 26 | uint8_t yAdvance; ///< Newline distance (y axis) 27 | } GFXfont; 28 | 29 | #endif // _GFXFONT_H_ 30 | -------------------------------------------------------------------------------- /icons/svgs/clear-night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 13 | 14 | -------------------------------------------------------------------------------- /icons/svgs/partly-cloudy-night.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eink-weather 2 | 3 | An e-ink weather display based on the [Inkplate 4 | 10](https://soldered.com/product/soldered-inkplate-10-9-7-e-paper-board-with-enclosure-copy/) 5 | (which takes a recycled Kindle display and pairs it with an ESP32, 6 | LiPo charger, SD card, RTC) and the Pirate Weather API. 7 | 8 | ![The display](display.jpg?raw=true "The display in use") 9 | 10 | # Build 11 | 12 | 1. Get an API key from [Pirate Weather](https://pirateweather.net/). 13 | 14 | 2. Modify params.h with the WiFi network details, latitude and 15 | longitude of the location you are getting weather for, the API 16 | key and set the title to be shown on the display. 17 | 18 | 3. Run make. This will build the PNG versions of the SVG icons. If 19 | you want to change/add icons the originals come from Erik Flowers' 20 | [Weather Icons](https://erikflowers.github.io/weather-icons/). 21 | 22 | Make will also build the fonts needed by the program. These are 23 | based on Google's [Roboto](https://fonts.google.com/specimen/Roboto). You 24 | can change the fonts by changing the ttf files in fonts/ttf and 25 | then run make. You'll need to update the .ino that references the 26 | generated .h files. 27 | 28 | 4. Copy the PNG files onto the SD card to be inserted in the Inkplate. 29 | There's a make target called "copy" which will do this but you 30 | may have to modify the Makefile to set the correct destination 31 | folder. 32 | 33 | 5. Build the .ino with the Arduino IDE and upload it to the Inkplate. 34 | 35 | 6. Run it either connected via USB-C or a LiPo battery. Since it sleeps 36 | drawing microamps the battery should last months between charges. -------------------------------------------------------------------------------- /icons/svgs/cloudy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /icons/svgs/fog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /icons/svgs/wind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 21 | 22 | -------------------------------------------------------------------------------- /icons/svgs/rain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 23 | 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 John Graham-Cumming 2 | 3 | include gmsl 4 | 5 | .PHONY: all 6 | all: 7 | 8 | # Used to build the PNG versions of SVG weather icons in the sizes 9 | # needed by the eink-weather project. The original icons come from 10 | # 11 | # https://erikflowers.github.io/weather-icons/ 12 | # 13 | # List of icon widths (in pixels) that are to be created. For each 14 | # .svg file .png versions are created with the width appended. For 15 | # example, rain.svg could become rain-24.png, rain-128.png etc. 16 | # 17 | 18 | WIDTHS := 43 128 19 | 20 | ICONS_DIR := icons/ 21 | SVGS_DIR := $(ICONS_DIR)svgs/ 22 | PNGS_DIR := $(ICONS_DIR)pngs/ 23 | 24 | $(shell mkdir -p $(PNGS_DIR)) 25 | 26 | SVGS := $(wildcard $(SVGS_DIR)*.svg) 27 | 28 | png-name = $(PNGS_DIR)$(subst $(SVGS_DIR),,$(subst .svg,,$1))-$2.png 29 | make-svg-to-png-rule = $(eval $(call png-name,$1,$2): $1 ; @rsvg-convert -d 150 -w $2 $$< -o $$@) 30 | 31 | PNGS := $(foreach w,$(WIDTHS), \ 32 | $(foreach s,$(SVGS), \ 33 | $(call png-name,$s,$w) \ 34 | $(call make-svg-to-png-rule,$s,$w))) 35 | 36 | all: $(PNGS) 37 | 38 | # copy copies the .png files to the SD card for insertion in the 39 | # Inkplate 40 | 41 | SDCARD := /Volumes/WEATHER 42 | 43 | .PHONY: copy 44 | copy: all ; @cp $(PNGS_DIR)* $(SDCARD) 45 | 46 | 47 | # This builds the Adafruit fontconvert executable needed to convert 48 | # fonts to the GFX format 49 | 50 | CC := gcc 51 | CFLAGS := -Wall -I/usr/local/include/freetype2 -I/usr/include/freetype2 -I/usr/include 52 | LIBS := -lfreetype 53 | 54 | fontconvert: fontconvert.c gfxfont.h 55 | @$(CC) $(CFLAGS) $< $(LIBS) -o $@ 56 | @strip $@ 57 | 58 | # This section converts the Roboto fonts from TTF to GFX format needed 59 | # by the project. Note that we specify an extended range of glyphs 60 | # because the default does not include the degree symbol. 61 | 62 | FONTS_DIR := fonts/ 63 | GFX_DIR := $(FONTS_DIR)gfx/ 64 | TTF_DIR := $(FONTS_DIR)ttf/ 65 | 66 | $(shell mkdir -p $(GFX_DIR)) 67 | 68 | FONTS := Roboto_Bold_12 Roboto_Regular_7 Roboto_Regular_24 69 | 70 | H_FILES := $(addprefix $(GFX_DIR),$(addsuffix .h,$(FONTS))) 71 | 72 | font-name = $(subst $(GFX_DIR),$(TTF_DIR),$(word 1,$(call split,_,$(subst .h,,$1)))-$(word 2,$(call split,_,$(subst .h,,$1)))).ttf 73 | font-size = $(lastword $(call split,_,$(subst .h,,$1))) 74 | $(foreach f,$(H_FILES),$(eval $f: $(call font-name,$f) ; @./fontconvert $$< $(call font-size,$f) 32 255 > $$@)) 75 | 76 | $(H_FILES): fontconvert 77 | all: $(H_FILES) 78 | 79 | # clean deletes all created files 80 | 81 | .PHONY: clean 82 | clean: ; @rm -f $(PNGS) fontconvert $(H_FILES) 83 | 84 | -------------------------------------------------------------------------------- /icons/svgs/clear-day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 27 | 28 | -------------------------------------------------------------------------------- /icons/svgs/snow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 27 | 28 | -------------------------------------------------------------------------------- /icons/svgs/partly-cloudy-day.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 29 | 30 | -------------------------------------------------------------------------------- /gmsl: -------------------------------------------------------------------------------- 1 | # ---------------------------------------------------------------------------- 2 | # 3 | # GNU Make Standard Library (GMSL) 4 | # 5 | # A library of functions to be used with GNU Make's $(call) that 6 | # provides functionality not available in standard GNU Make. 7 | # 8 | # Copyright (c) 2005-2022 John Graham-Cumming 9 | # 10 | # This file is part of GMSL 11 | # 12 | # Redistribution and use in source and binary forms, with or without 13 | # modification, are permitted provided that the following conditions 14 | # are met: 15 | # 16 | # Redistributions of source code must retain the above copyright 17 | # notice, this list of conditions and the following disclaimer. 18 | # 19 | # Redistributions in binary form must reproduce the above copyright 20 | # notice, this list of conditions and the following disclaimer in the 21 | # documentation and/or other materials provided with the distribution. 22 | # 23 | # Neither the name of the John Graham-Cumming nor the names of its 24 | # contributors may be used to endorse or promote products derived from 25 | # this software without specific prior written permission. 26 | # 27 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 28 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 29 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 30 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 31 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 32 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 33 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 34 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 35 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 36 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 37 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 38 | # POSSIBILITY OF SUCH DAMAGE. 39 | # 40 | # ---------------------------------------------------------------------------- 41 | 42 | # Determine if the library has already been included and if so don't 43 | # bother including it again 44 | 45 | ifndef __gmsl_included 46 | 47 | # Standard definitions for true and false. true is any non-empty 48 | # string, false is an empty string. These are intended for use with 49 | # $(if). 50 | 51 | true := T 52 | false := 53 | 54 | # ---------------------------------------------------------------------------- 55 | # Function: not 56 | # Arguments: 1: A boolean value 57 | # Returns: Returns the opposite of the arg. (true -> false, false -> true) 58 | # ---------------------------------------------------------------------------- 59 | not = $(if $1,$(false),$(true)) 60 | 61 | # Prevent reinclusion of the library 62 | 63 | __gmsl_included := $(true) 64 | 65 | # Try to determine where this file is located. If the caller did 66 | # include /foo/gmsl then extract the /foo/ so that __gmsl gets 67 | # included transparently 68 | 69 | __gmsl_root := 70 | 71 | ifneq ($(MAKEFILE_LIST),) 72 | __gmsl_root := $(word $(words $(MAKEFILE_LIST)),$(MAKEFILE_LIST)) 73 | 74 | # If there are any spaces in the path in __gmsl_root then give up 75 | 76 | ifeq (1,$(words $(__gmsl_root))) 77 | __gmsl_root := $(patsubst %gmsl,%,$(__gmsl_root)) 78 | endif 79 | 80 | endif 81 | 82 | include $(__gmsl_root)__gmsl 83 | 84 | endif # __gmsl_included 85 | 86 | -------------------------------------------------------------------------------- /icons/svgs/sleet.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 35 | 36 | -------------------------------------------------------------------------------- /fontconvert.c: -------------------------------------------------------------------------------- 1 | /* 2 | TrueType to Adafruit_GFX font converter. Derived from Peter Jakobs' 3 | Adafruit_ftGFX fork & makefont tool, and Paul Kourany's Adafruit_mfGFX. 4 | 5 | NOT AN ARDUINO SKETCH. This is a command-line tool for preprocessing 6 | fonts to be used with the Adafruit_GFX Arduino library. 7 | 8 | For UNIX-like systems. Outputs to stdout; redirect to header file, e.g.: 9 | ./fontconvert ~/Library/Fonts/FreeSans.ttf 18 > FreeSans18pt7b.h 10 | 11 | REQUIRES FREETYPE LIBRARY. www.freetype.org 12 | 13 | Currently this only extracts the printable 7-bit ASCII chars of a font. 14 | Will eventually extend with some int'l chars a la ftGFX, not there yet. 15 | Keep 7-bit fonts around as an option in that case, more compact. 16 | 17 | See notes at end for glyph nomenclature & other tidbits. 18 | */ 19 | #ifndef ARDUINO 20 | 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include FT_GLYPH_H 26 | #include FT_MODULE_H 27 | #include FT_TRUETYPE_DRIVER_H 28 | #include "gfxfont.h" // Adafruit_GFX font structures 29 | 30 | #define DPI 141 // Approximate res. of Adafruit 2.8" TFT 31 | 32 | // Accumulate bits for output, with periodic hexadecimal byte write 33 | void enbit(uint8_t value) { 34 | static uint8_t row = 0, sum = 0, bit = 0x80, firstCall = 1; 35 | if (value) 36 | sum |= bit; // Set bit if needed 37 | if (!(bit >>= 1)) { // Advance to next bit, end of byte reached? 38 | if (!firstCall) { // Format output table nicely 39 | if (++row >= 12) { // Last entry on line? 40 | printf(",\n "); // Newline format output 41 | row = 0; // Reset row counter 42 | } else { // Not end of line 43 | printf(", "); // Simple comma delim 44 | } 45 | } 46 | printf("0x%02X", sum); // Write byte value 47 | sum = 0; // Clear for next byte 48 | bit = 0x80; // Reset bit counter 49 | firstCall = 0; // Formatting flag 50 | } 51 | } 52 | 53 | int main(int argc, char *argv[]) { 54 | int i, j, err, size, first = ' ', last = '~', bitmapOffset = 0, x, y, byte; 55 | char *fontName, c, *ptr; 56 | FT_Library library; 57 | FT_Face face; 58 | FT_Glyph glyph; 59 | FT_Bitmap *bitmap; 60 | FT_BitmapGlyphRec *g; 61 | GFXglyph *table; 62 | uint8_t bit; 63 | 64 | // Parse command line. Valid syntaxes are: 65 | // fontconvert [filename] [size] 66 | // fontconvert [filename] [size] [last char] 67 | // fontconvert [filename] [size] [first char] [last char] 68 | // Unless overridden, default first and last chars are 69 | // ' ' (space) and '~', respectively 70 | 71 | if (argc < 3) { 72 | fprintf(stderr, "Usage: %s fontfile size [first] [last]\n", argv[0]); 73 | return 1; 74 | } 75 | 76 | size = atoi(argv[2]); 77 | 78 | if (argc == 4) { 79 | last = atoi(argv[3]); 80 | } else if (argc == 5) { 81 | first = atoi(argv[3]); 82 | last = atoi(argv[4]); 83 | } 84 | 85 | if (last < first) { 86 | i = first; 87 | first = last; 88 | last = i; 89 | } 90 | 91 | ptr = strrchr(argv[1], '/'); // Find last slash in filename 92 | if (ptr) 93 | ptr++; // First character of filename (path stripped) 94 | else 95 | ptr = argv[1]; // No path; font in local dir. 96 | 97 | // Allocate space for font name and glyph table 98 | if ((!(fontName = malloc(strlen(ptr) + 20))) || 99 | (!(table = (GFXglyph *)malloc((last - first + 1) * sizeof(GFXglyph))))) { 100 | fprintf(stderr, "Malloc error\n"); 101 | return 1; 102 | } 103 | 104 | // Derive font table names from filename. Period (filename 105 | // extension) is truncated and replaced with the font size & bits. 106 | strcpy(fontName, ptr); 107 | ptr = strrchr(fontName, '.'); // Find last period (file ext) 108 | if (!ptr) 109 | ptr = &fontName[strlen(fontName)]; // If none, append 110 | // Insert font size and 7/8 bit. fontName was alloc'd w/extra 111 | // space to allow this, we're not sprintfing into Forbidden Zone. 112 | sprintf(ptr, "%dpt%db", size, (last > 127) ? 8 : 7); 113 | // Space and punctuation chars in name replaced w/ underscores. 114 | for (i = 0; (c = fontName[i]); i++) { 115 | if (isspace(c) || ispunct(c)) 116 | fontName[i] = '_'; 117 | } 118 | 119 | // Init FreeType lib, load font 120 | if ((err = FT_Init_FreeType(&library))) { 121 | fprintf(stderr, "FreeType init error: %d", err); 122 | return err; 123 | } 124 | 125 | // Use TrueType engine version 35, without subpixel rendering. 126 | // This improves clarity of fonts since this library does not 127 | // support rendering multiple levels of gray in a glyph. 128 | // See https://github.com/adafruit/Adafruit-GFX-Library/issues/103 129 | FT_UInt interpreter_version = TT_INTERPRETER_VERSION_35; 130 | FT_Property_Set(library, "truetype", "interpreter-version", 131 | &interpreter_version); 132 | 133 | if ((err = FT_New_Face(library, argv[1], 0, &face))) { 134 | fprintf(stderr, "Font load error: %d", err); 135 | FT_Done_FreeType(library); 136 | return err; 137 | } 138 | 139 | // << 6 because '26dot6' fixed-point format 140 | FT_Set_Char_Size(face, size << 6, 0, DPI, 0); 141 | 142 | // Currently all symbols from 'first' to 'last' are processed. 143 | // Fonts may contain WAY more glyphs than that, but this code 144 | // will need to handle encoding stuff to deal with extracting 145 | // the right symbols, and that's not done yet. 146 | // fprintf(stderr, "%ld glyphs\n", face->num_glyphs); 147 | 148 | printf("const uint8_t %sBitmaps[] PROGMEM = {\n ", fontName); 149 | 150 | // Process glyphs and output huge bitmap data array 151 | for (i = first, j = 0; i <= last; i++, j++) { 152 | // MONO renderer provides clean image with perfect crop 153 | // (no wasted pixels) via bitmap struct. 154 | if ((err = FT_Load_Char(face, i, FT_LOAD_TARGET_MONO))) { 155 | fprintf(stderr, "Error %d loading char '%c'\n", err, i); 156 | continue; 157 | } 158 | 159 | if ((err = FT_Render_Glyph(face->glyph, FT_RENDER_MODE_MONO))) { 160 | fprintf(stderr, "Error %d rendering char '%c'\n", err, i); 161 | continue; 162 | } 163 | 164 | if ((err = FT_Get_Glyph(face->glyph, &glyph))) { 165 | fprintf(stderr, "Error %d getting glyph '%c'\n", err, i); 166 | continue; 167 | } 168 | 169 | bitmap = &face->glyph->bitmap; 170 | g = (FT_BitmapGlyphRec *)glyph; 171 | 172 | // Minimal font and per-glyph information is stored to 173 | // reduce flash space requirements. Glyph bitmaps are 174 | // fully bit-packed; no per-scanline pad, though end of 175 | // each character may be padded to next byte boundary 176 | // when needed. 16-bit offset means 64K max for bitmaps, 177 | // code currently doesn't check for overflow. (Doesn't 178 | // check that size & offsets are within bounds either for 179 | // that matter...please convert fonts responsibly.) 180 | table[j].bitmapOffset = bitmapOffset; 181 | table[j].width = bitmap->width; 182 | table[j].height = bitmap->rows; 183 | table[j].xAdvance = face->glyph->advance.x >> 6; 184 | table[j].xOffset = g->left; 185 | table[j].yOffset = 1 - g->top; 186 | 187 | for (y = 0; y < bitmap->rows; y++) { 188 | for (x = 0; x < bitmap->width; x++) { 189 | byte = x / 8; 190 | bit = 0x80 >> (x & 7); 191 | enbit(bitmap->buffer[y * bitmap->pitch + byte] & bit); 192 | } 193 | } 194 | 195 | // Pad end of char bitmap to next byte boundary if needed 196 | int n = (bitmap->width * bitmap->rows) & 7; 197 | if (n) { // Pixel count not an even multiple of 8? 198 | n = 8 - n; // # bits to next multiple 199 | while (n--) 200 | enbit(0); 201 | } 202 | bitmapOffset += (bitmap->width * bitmap->rows + 7) / 8; 203 | 204 | FT_Done_Glyph(glyph); 205 | } 206 | 207 | printf(" };\n\n"); // End bitmap array 208 | 209 | // Output glyph attributes table (one per character) 210 | printf("const GFXglyph %sGlyphs[] PROGMEM = {\n", fontName); 211 | for (i = first, j = 0; i <= last; i++, j++) { 212 | printf(" { %5d, %3d, %3d, %3d, %4d, %4d }", table[j].bitmapOffset, 213 | table[j].width, table[j].height, table[j].xAdvance, table[j].xOffset, 214 | table[j].yOffset); 215 | if (i < last) { 216 | printf(", // 0x%02X", i); 217 | if ((i >= ' ') && (i <= '~')) { 218 | printf(" '%c'", i); 219 | } 220 | putchar('\n'); 221 | } 222 | } 223 | printf(" }; // 0x%02X", last); 224 | if ((last >= ' ') && (last <= '~')) 225 | printf(" '%c'", last); 226 | printf("\n\n"); 227 | 228 | // Output font structure 229 | printf("const GFXfont %s PROGMEM = {\n", fontName); 230 | printf(" (uint8_t *)%sBitmaps,\n", fontName); 231 | printf(" (GFXglyph *)%sGlyphs,\n", fontName); 232 | if (face->size->metrics.height == 0) { 233 | // No face height info, assume fixed width and get from a glyph. 234 | printf(" 0x%02X, 0x%02X, %d };\n\n", first, last, table[0].height); 235 | } else { 236 | printf(" 0x%02X, 0x%02X, %ld };\n\n", first, last, 237 | face->size->metrics.height >> 6); 238 | } 239 | printf("// Approx. %d bytes\n", bitmapOffset + (last - first + 1) * 7 + 7); 240 | // Size estimate is based on AVR struct and pointer sizes; 241 | // actual size may vary. 242 | 243 | FT_Done_FreeType(library); 244 | 245 | return 0; 246 | } 247 | 248 | /* ------------------------------------------------------------------------- 249 | 250 | Character metrics are slightly different from classic GFX & ftGFX. 251 | In classic GFX: cursor position is the upper-left pixel of each 5x7 252 | character; lower extent of most glyphs (except those w/descenders) 253 | is +6 pixels in Y direction. 254 | W/new GFX fonts: cursor position is on baseline, where baseline is 255 | 'inclusive' (containing the bottom-most row of pixels in most symbols, 256 | except those with descenders; ftGFX is one pixel lower). 257 | 258 | Cursor Y will be moved automatically when switching between classic 259 | and new fonts. If you switch fonts, any print() calls will continue 260 | along the same baseline. 261 | 262 | ...........#####.. -- yOffset 263 | ..........######.. 264 | ..........######.. 265 | .........#######.. 266 | ........#########. 267 | * = Cursor pos. ........#########. 268 | .......##########. 269 | ......#####..####. 270 | ......#####..####. 271 | *.#.. .....#####...####. 272 | .#.#. ....############## 273 | #...# ...############### 274 | #...# ...############### 275 | ##### ..#####......##### 276 | #...# .#####.......##### 277 | ====== #...# ====== #*###.........#### ======= Baseline 278 | || xOffset 279 | 280 | glyph->xOffset and yOffset are pixel offsets, in GFX coordinate space 281 | (+Y is down), from the cursor position to the top-left pixel of the 282 | glyph bitmap. i.e. yOffset is typically negative, xOffset is typically 283 | zero but a few glyphs will have other values (even negative xOffsets 284 | sometimes, totally normal). glyph->xAdvance is the distance to move 285 | the cursor on the X axis after drawing the corresponding symbol. 286 | 287 | There's also some changes with regard to 'background' color and new GFX 288 | fonts (classic fonts unchanged). See Adafruit_GFX.cpp for explanation. 289 | */ 290 | 291 | #endif /* !ARDUINO */ 292 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /eink_weather.ino: -------------------------------------------------------------------------------- 1 | // eink_weather: this project uses an Inkplate 10 display and the 2 | // Pirate Weather API to get a weather forecast for a given latitude 3 | // and longitude and display it. 4 | // 5 | // Copyright (c) 2023 John Graham-Cumming 6 | 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | #include "params.h" 15 | 16 | #include "fonts/gfx/Roboto_Regular_7.h" 17 | #include "fonts/gfx/Roboto_Regular_24.h" 18 | #include "fonts/gfx/Roboto_Bold_12.h" 19 | 20 | const GFXfont *fontSmall = &Roboto_Regular7pt8b; 21 | const GFXfont *fontMedium = &Roboto_Bold12pt8b; 22 | const GFXfont *fontLarge = &Roboto_Regular24pt8b; 23 | 24 | // The default time when the RTC is not set is January 1, 2066 00:00 25 | 26 | #define DEFAULT_EPOCH 3029529605 27 | 28 | Inkplate ink(INKPLATE_3BIT); 29 | 30 | // This structure and the array are used to blend the historical weather 31 | // forecast with an up to date forecast. That way the hourly data shown 32 | // for 48 hours is a mix of what was predicted at midnight today and what 33 | // is predicted now. 34 | 35 | #define ICON_SIZE 64 36 | struct hour_slot { 37 | uint32_t when; 38 | float temperature; 39 | char icon[ICON_SIZE]; 40 | }; 41 | 42 | struct hour_slot hours[48]; 43 | 44 | #define SECONDS_PER_HOUR (60*60) 45 | #define SECONDS_PER_DAY (24*SECONDS_PER_HOUR) 46 | 47 | // setup runs the entire program and then goes to sleep. 48 | void setup() { 49 | ink.begin(); 50 | 51 | // Default so that on first start up if WiFi doesn't connect then 52 | // will try again in 60 seconds. 53 | 54 | uint64_t sleep_time = 60; 55 | 56 | if (connectWiFi(wifi_count, wifi_networks, wifi_passwords)) { 57 | if (setRTC()) { 58 | 59 | // Figure out which of the update_times[] array is the next minute 60 | // past the hour at which the display should update. 61 | 62 | uint32_t now = getRtcNow(); 63 | uint32_t next = now + sleep_time; 64 | 65 | if (now < DEFAULT_EPOCH) { 66 | uint32_t this_hour = now / SECONDS_PER_HOUR; 67 | this_hour *= SECONDS_PER_HOUR; 68 | 69 | for (int h = 0; h < 2; h++) { 70 | for (int i = 0; i < UPDATE_TIMES; i++) { 71 | next = this_hour + h * SECONDS_PER_HOUR + update_times[i] * 60; 72 | if (next > now) { 73 | h = 2; 74 | break; 75 | } 76 | } 77 | } 78 | } 79 | 80 | if (ink.sdCardInit() != 0) { 81 | showWeather(now, next); 82 | 83 | if (next > now) { 84 | sleep_time = next - now; 85 | } 86 | } else { 87 | fatal("SD card failed to initialize"); 88 | } 89 | } else { 90 | fatal("Failed to set RTC from NTP"); 91 | } 92 | 93 | disconnectWiFi(); 94 | } else { 95 | fatal("Failed to connect to WiFi "); 96 | } 97 | 98 | deepSleep(sleep_time); 99 | } 100 | 101 | // loop contains nothing because the entire sketch will be woken up 102 | // in setup(), do work and then go to sleep again. 103 | void loop() {} 104 | 105 | // connectWiFi connects to the passed in WiFi network and returns true 106 | // if successful. 107 | bool connectWiFi(int count, const char **ssids, const char **passes) { 108 | return ink.connectWiFiMulti(count, ssids, passes, 23, true); 109 | } 110 | 111 | // disconnectWiFi cleans up the result of connecting via connectWiFi 112 | void disconnectWiFi() { 113 | ink.disconnect(); 114 | } 115 | 116 | // setRTC sets the RTC via NTP and returns true is successful 117 | bool setRTC() { 118 | 119 | // First 0 means no offset from GMT, second 0 means no daylight savings time. 120 | 121 | configTime(0, 0, "pool.ntp.org", "time.nist.gov", "time.cloudflare.com"); 122 | 123 | int tries = 5; 124 | 125 | while (tries > 0) { 126 | delay(2000); 127 | tries -= 1; 128 | 129 | uint32_t fromntp = time(NULL); 130 | 131 | // If time from NTP doesn't look like it's been set then wait 132 | 133 | if (fromntp < 1681141968) { 134 | continue; 135 | } 136 | 137 | Serial.print("Time from NTP: "); 138 | Serial.println(fromntp); 139 | 140 | ink.rtcSetEpoch(fromntp); 141 | return true; 142 | } 143 | 144 | return false; 145 | } 146 | 147 | // gtcRtcNow returns the current epoch time from the RTC 148 | uint32_t getRtcNow() { 149 | ink.rtcGetRtcData(); 150 | return ink.rtcGetEpoch(); 151 | } 152 | 153 | const char days[7][4] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; 154 | 155 | // day converts a Unix epoch to the current day of the week taking into 156 | // account the timezone offset in hours. 157 | const char *day(uint32_t when, float offset) { 158 | when += int(offset * SECONDS_PER_HOUR); 159 | int dow = int(floor(when / SECONDS_PER_DAY) + 4) % 7; 160 | return days[dow]; 161 | } 162 | 163 | #define HHMM_SIZE 6 164 | 165 | // hhmm converts a Unix epoch to hh:mm taking into account the timezone 166 | // offset in hours. out just be at least HHMM_SIZE. 167 | void hhmm(uint32_t when, float offset, char *out) { 168 | when += int(offset * SECONDS_PER_HOUR); 169 | int hh = int(floor(when / SECONDS_PER_HOUR)) % 24; 170 | int mm = int(floor(when / 60)) % 60; 171 | sprintf(out, "%02d:%02d", hh, mm); 172 | } 173 | 174 | // prHelper does the actual printing of text. It takes an (x, y) position 175 | // of the text, the text and font. Plus two parameters wm and hm which 176 | // are multipliers to apply to the width and height of the text being 177 | // printed. If the width and height are w and h then this will calculate 178 | // x + wm*w, y + hm*h. 179 | void prHelper(int16_t x, int16_t y, const char *text, const GFXfont *f, 180 | float wm = 0, float hm = 0) { 181 | int16_t x1; 182 | int16_t y1; 183 | uint16_t w; 184 | uint16_t h; 185 | ink.setFont(f); 186 | ink.setTextSize(1); 187 | ink.getTextBounds(text, 0, 0, &x1, &y1, &w, &h); 188 | ink.setCursor(x + w * wm, y + h * hm); 189 | ink.print(text); 190 | } 191 | 192 | // pr writes text at (x, y) with font f. 193 | void pr(int16_t x, int16_t y, const char *text, const GFXfont *f) { 194 | prHelper(x, y, text, f); 195 | } 196 | 197 | // centre centres a piece of text at the x, y position. 198 | void centre(int16_t x, int16_t y, const char *text, const GFXfont *f) { 199 | prHelper(x, y, text, f, -0.5); 200 | } 201 | 202 | // right right-justifies text x, y position. 203 | void right(int16_t x, int16_t y, const char *text, const GFXfont *f) { 204 | prHelper(x, y, text, f, -1); 205 | } 206 | 207 | // flushRight right-justifies text at the y position with size s. 208 | void flushRight(int16_t y, const char *text, const GFXfont *f) { 209 | prHelper(ink.width() - 20, y, text, f, -1); 210 | } 211 | 212 | #define SMALL_IMAGE 43 213 | #define LARGE_IMAGE 128 214 | 215 | // centreIcon draws an icon cenetred at the x, y position. If the 216 | // icon name is partly-cloudy-day and the size s is 43 then this 217 | // will load partly-cloudy-day-42.png. 218 | void centreIcon(int16_t x, int16_t y, const char *img, int16_t s) { 219 | char tmp[100]; 220 | sprintf(tmp, "%s-%d.png", img, s); 221 | 222 | // This assumes that icon are squares 223 | 224 | ink.drawImage(tmp, x - s / 2, y - s / 2); 225 | } 226 | 227 | // drawRectangle draws a rectangle given the top left corner and 228 | // width and height. We don't use the built in ink.drawRect because 229 | // the lines are thinner than a one pixel line drawn by drawThickLine. 230 | void drawRectangle(int16_t x0, int16_t y0, int16_t w, int16_t h) { 231 | int16_t x1 = x0 + w; 232 | int16_t y1 = y0 + h; 233 | ink.drawThickLine(x0, y0, x1, y0, 0, 1); 234 | ink.drawThickLine(x0, y1, x1, y1, 0, 1); 235 | ink.drawThickLine(x0, y0, x0, y1, 0, 1); 236 | ink.drawThickLine(x1, y0, x1, y1, 0, 1); 237 | } 238 | 239 | #define TEMP_SIZE 6 240 | 241 | // roundTemp rounds a floating point temperature and write to a string 242 | // that contains the temperature. out must be at least TEMP_SIZE. 243 | void roundTemp(float t, char *out) { 244 | 245 | // Temperatures are rounded up if above 0 and down if below 0. 246 | // Example: 1.4C becomes 1C, 1.6C becomes 2C, -0.4C becomes 0C, 247 | // -1.7C becomes -2C. 248 | 249 | if (t >= 0) { 250 | t += 0.5; 251 | } else { 252 | t -= 0.5; 253 | } 254 | 255 | sprintf(out, "%d", int(t)); 256 | } 257 | 258 | // CA certificate for the domain being connected to using TLS. Obtained 259 | // using the openssl s_client as follows: 260 | // 261 | // openssl s_client -showcerts -servername api.pirateweather.net \ 262 | // -connect api.pirateweather.net:443 263 | // 264 | // The certificate below is the last certificate output by openssl as it 265 | // is the CA certificate of the domain above. 266 | const char *cacert = \ 267 | "-----BEGIN CERTIFICATE-----\n" \ 268 | "MIIEdTCCA12gAwIBAgIJAKcOSkw0grd/MA0GCSqGSIb3DQEBCwUAMGgxCzAJBgNV\n" \ 269 | "BAYTAlVTMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTIw\n" \ 270 | "MAYDVQQLEylTdGFyZmllbGQgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0\n" \ 271 | "eTAeFw0wOTA5MDIwMDAwMDBaFw0zNDA2MjgxNzM5MTZaMIGYMQswCQYDVQQGEwJV\n" \ 272 | "UzEQMA4GA1UECBMHQXJpem9uYTETMBEGA1UEBxMKU2NvdHRzZGFsZTElMCMGA1UE\n" \ 273 | "ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjE7MDkGA1UEAxMyU3RhcmZp\n" \ 274 | "ZWxkIFNlcnZpY2VzIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IC0gRzIwggEi\n" \ 275 | "MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVDDrEKvlO4vW+GZdfjohTsR8/\n" \ 276 | "y8+fIBNtKTrID30892t2OGPZNmCom15cAICyL1l/9of5JUOG52kbUpqQ4XHj2C0N\n" \ 277 | "Tm/2yEnZtvMaVq4rtnQU68/7JuMauh2WLmo7WJSJR1b/JaCTcFOD2oR0FMNnngRo\n" \ 278 | "Ot+OQFodSk7PQ5E751bWAHDLUu57fa4657wx+UX2wmDPE1kCK4DMNEffud6QZW0C\n" \ 279 | "zyyRpqbn3oUYSXxmTqM6bam17jQuug0DuDPfR+uxa40l2ZvOgdFFRjKWcIfeAg5J\n" \ 280 | "Q4W2bHO7ZOphQazJ1FTfhy/HIrImzJ9ZVGif/L4qL8RVHHVAYBeFAlU5i38FAgMB\n" \ 281 | "AAGjgfAwge0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0O\n" \ 282 | "BBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMB8GA1UdIwQYMBaAFL9ft9HO3R+G9FtV\n" \ 283 | "rNzXEMIOqYjnME8GCCsGAQUFBwEBBEMwQTAcBggrBgEFBQcwAYYQaHR0cDovL28u\n" \ 284 | "c3MyLnVzLzAhBggrBgEFBQcwAoYVaHR0cDovL3guc3MyLnVzL3guY2VyMCYGA1Ud\n" \ 285 | "HwQfMB0wG6AZoBeGFWh0dHA6Ly9zLnNzMi51cy9yLmNybDARBgNVHSAECjAIMAYG\n" \ 286 | "BFUdIAAwDQYJKoZIhvcNAQELBQADggEBACMd44pXyn3pF3lM8R5V/cxTbj5HD9/G\n" \ 287 | "VfKyBDbtgB9TxF00KGu+x1X8Z+rLP3+QsjPNG1gQggL4+C/1E2DUBc7xgQjB3ad1\n" \ 288 | "l08YuW3e95ORCLp+QCztweq7dp4zBncdDQh/U90bZKuCJ/Fp1U1ervShw3WnWEQt\n" \ 289 | "8jxwmKy6abaVd38PMV4s/KCHOkdp8Hlf9BRUpJVeEXgSYCfOn8J3/yNTd126/+pZ\n" \ 290 | "59vPr5KW7ySaNRB6nJHGDn2Z9j8Z3/VyVOEVqQdZe4O/Ui5GjLIAZHYcSNPYeehu\n" \ 291 | "VsyuLAOQ1xk4meTKCRlb/weWsKh/NEnfVqn3sF/tM+2MR7cwA130A4w=\n" \ 292 | "-----END CERTIFICATE-----\n"; 293 | 294 | // callAPI calls the Pirate Weather API with a set of excluded sections (which 295 | // must not be empty) and an epoch time for when the weather forecast should 296 | // start. Either returns the HTTP body as a String or an empty string for an 297 | // error. If when is zero then gets the current forecast. 298 | String callAPI(char *exclude, uint32_t when) { 299 | WiFiClientSecure tls; 300 | tls.setCACert(cacert); 301 | HTTPClient http; 302 | 303 | // The API URL. A number of elements of the response can be filtered 304 | // using exclude to minimize the size of the JSON response. 305 | 306 | const char *api_format = \ 307 | "https://api.pirateweather.net/forecast/" \ 308 | "%s/%s,%s%s" \ 309 | "?units=%s" \ 310 | "&tz=precise" \ 311 | "&exclude=%s"; 312 | 313 | char api[232]; 314 | char whens[32] = ""; 315 | if (when != 0) { 316 | sprintf(whens, ",%d", when); 317 | } 318 | sprintf(api, api_format, api_key, lat, lon, whens, units, exclude); 319 | 320 | int tries = 5; 321 | 322 | // Retry API calls at most five times with two seconds between 323 | // retries. 324 | 325 | int code; 326 | 327 | while (tries > 0) { 328 | tries -= 1; 329 | 330 | if (http.begin(tls, api)) { 331 | code = http.GET(); 332 | if (code == HTTP_CODE_OK) { 333 | return http.getString(); 334 | } 335 | } 336 | 337 | delay(2000); 338 | } 339 | 340 | fatal("Pirate Weather API call failed " + http.errorToString(code)); 341 | return ""; 342 | } 343 | 344 | // showWeather gets and displays the weather forecast on screen. 345 | void showWeather(uint32_t update_time, uint32_t next) { 346 | 347 | // Step 1. 348 | // 349 | // Since the hourly section of the Pirate Weather API returns the next 350 | // 48 hours and we want today and tomorrow we need to ask for midnight 351 | // today. This is done by retrieving the time now from the RTC. But there's 352 | // a hitch: we want local time so... we first make an API call to the 353 | // Pirate Weather API excluding all sections so we can just extract the UTC 354 | // offset. 355 | 356 | uint32_t now = getRtcNow(); 357 | 358 | // This means that clock hasn't been set 359 | 360 | if (now >= 3029529605) { 361 | return; 362 | } 363 | 364 | String response = callAPI("currently,minutely,hourly,daily,alerts", 0); 365 | if (response == "") { 366 | return; 367 | } 368 | 369 | // This isn't efficient because the return from http.getString() is 370 | // a String which will get duplicated by deserializeJson. Size 371 | // determined using https://arduinojson.org/v6/assistant/#/step1 372 | 373 | StaticJsonDocument<1536> jsonTiming; 374 | DeserializationError err = deserializeJson(jsonTiming, response); 375 | 376 | if (err) { 377 | fatal("Deserialize JSON failed " + String(err.c_str())); 378 | return; 379 | } 380 | 381 | // This will be the delta (in hours) between UTC and the local time. For example, 382 | // in the UK during the summer this will be 1 and to go from UTC to local time 383 | // you must add 1. Note that this is a float because the offset could be 1.5 hours 384 | // or similar (e.g. India is 5.5 hours from UTC). 385 | 386 | float offset = jsonTiming["offset"]; 387 | const char *tz = jsonTiming["timezone"]; 388 | 389 | // Figure out the epoch equivalent of midnight local time. First round to the nearest day. 390 | // Since the epoch starts on a midnight boundary this will give us UTC midnight for today. 391 | // The subtract the offset hours to get the UTC time that corresponds to local midnight. 392 | 393 | uint32_t seconds_since_midnight = now; 394 | 395 | // Do not be tempted to remove these two lines. This is integer division so this is used 396 | // for rounding! 397 | 398 | now /= SECONDS_PER_DAY; 399 | now *= SECONDS_PER_DAY; 400 | 401 | uint32_t midnight = now - int(offset * SECONDS_PER_HOUR); 402 | seconds_since_midnight -= midnight; 403 | 404 | // Step 2. 405 | // 406 | // Now get the historial weather forecast from the calculated local midnight and insert 407 | // it into the hours array. 408 | 409 | response = callAPI("currently,daily,minutely,alerts", midnight); 410 | if (response == "") { 411 | return; 412 | } 413 | 414 | DynamicJsonDocument doc(32768); 415 | err = deserializeJson(doc, response); 416 | 417 | if (err) { 418 | fatal("Deserialize JSON failed " + String(err.c_str())); 419 | return; 420 | } 421 | 422 | int i = 0; 423 | 424 | JsonObject hourly = doc["hourly"]; 425 | for (JsonObject hourly_data_item : hourly["data"].as()) { 426 | hours[i].when = hourly_data_item["time"]; 427 | hours[i].temperature = hourly_data_item["temperature"]; 428 | strcpy(hours[i].icon, hourly_data_item["icon"]); 429 | i += 1; 430 | } 431 | 432 | // Step 3. 433 | // 434 | // Then get the current forecast and overwrite entries in the hours 435 | // array so we blend the historical forecast and the current one. 436 | 437 | response = callAPI("currently,minutely,alerts", 0); 438 | if (response == "") { 439 | return; 440 | } 441 | 442 | doc.clear(); 443 | err = deserializeJson(doc, response); 444 | 445 | if (err) { 446 | fatal("Deserialize JSON failed " + String(err.c_str())); 447 | return; 448 | } 449 | 450 | hourly = doc["hourly"]; 451 | for (JsonObject hourly_data_item : hourly["data"].as()) { 452 | for (i = 0; i < 48; i++) { 453 | if (hours[i].when == hourly_data_item["time"]) { 454 | hours[i].temperature = hourly_data_item["temperature"]; 455 | strcpy(hours[i].icon, hourly_data_item["icon"]); 456 | } 457 | } 458 | } 459 | 460 | // Step 4. 461 | // 462 | // Draw the hourly data on screen and since the last API call included the data 463 | // for the next 7 days draw the daily forecast as well. 464 | 465 | int16_t bar_start_x = 50; 466 | int16_t bar_start_y = 200; 467 | 468 | // This ensure that the bar_width is a multiple of 24 so that the hours are spaced 469 | // at an integer number of pixels. 470 | 471 | int16_t bar_width = ink.width() - 2 * bar_start_x; 472 | int16_t temp_bar_width = bar_width; 473 | bar_width /= 24; 474 | int16_t bar_hour_spacing = bar_width; 475 | bar_width *= 24; 476 | 477 | bar_start_x += (temp_bar_width - bar_width) / 2; 478 | 479 | int16_t bar_height = 100; 480 | int16_t bar_gap = 100; 481 | 482 | drawRectangle(bar_start_x, bar_start_y, bar_width, bar_height); 483 | drawRectangle(bar_start_x, bar_start_y + bar_height + bar_gap, bar_width, bar_height); 484 | 485 | // This draws a marker showing the current time relative to the today weather forecast 486 | 487 | uint32_t now_offset = ((uint32_t)bar_width * seconds_since_midnight) / SECONDS_PER_DAY; 488 | int16_t now_x = bar_start_x + now_offset; 489 | ink.fillTriangle(now_x - 3, bar_start_y - 7, now_x + 3, bar_start_y - 6, now_x, bar_start_y - 1, 1); 490 | 491 | ink.setTextColor(0, 7); 492 | pr(bar_start_x, bar_start_y - 7, "Today", fontMedium); 493 | pr(bar_start_x, bar_start_y + bar_height + bar_gap - 7, "Tomorrow", fontMedium); 494 | 495 | int16_t bar_y = bar_start_y; 496 | int16_t bar_x = bar_start_x; 497 | int16_t short_tick = 5; 498 | int16_t long_tick = 10; 499 | 500 | int16_t last_x = -1; 501 | char last[128]; 502 | for (i = 0; i < 48; i++) { 503 | ink.drawThickLine(bar_x, bar_y + bar_height, bar_x, 504 | bar_y + bar_height + ((i % 2 == 0) ? short_tick : long_tick), 0, 1); 505 | 506 | if ((i % 2) == 0) { 507 | char hour[HHMM_SIZE]; 508 | hhmm(hours[i].when, offset, &hour[0]); 509 | char temp[TEMP_SIZE]; 510 | roundTemp(hours[i].temperature, &temp[0]); 511 | if (i == 0) { 512 | pr(bar_x, bar_y + bar_height + short_tick + 12, hour, fontSmall); 513 | pr(bar_x, bar_y + bar_height + short_tick + 36, temp, fontMedium); 514 | } else { 515 | centre(bar_x, bar_y + bar_height + short_tick + 12, hour, fontSmall); 516 | centre(bar_x, bar_y + bar_height + short_tick + 36, temp, fontMedium); 517 | } 518 | ink.print("\xba"); 519 | } 520 | 521 | if (last_x == -1) { 522 | strcpy(last, hours[i].icon); 523 | last_x = bar_x; 524 | } else { 525 | if (strcmp(last, hours[i].icon) != 0) { 526 | ink.drawThickLine(bar_x, bar_y, bar_x, bar_y + bar_height, 0, 1); 527 | centreIcon(last_x + (bar_x - last_x) / 2, bar_y + bar_height / 2, last, SMALL_IMAGE); 528 | strcpy(last, hours[i].icon); 529 | last_x = bar_x; 530 | } 531 | } 532 | 533 | bar_x += bar_hour_spacing; 534 | if ((i % 24) == 23) { 535 | centreIcon(last_x + (bar_x - last_x) / 2, bar_y + bar_height / 2, last, SMALL_IMAGE); 536 | bar_x = bar_start_x; 537 | bar_y += bar_height + bar_gap; 538 | last_x = -1; 539 | } 540 | } 541 | 542 | bar_width /= 2; 543 | bar_width /= 7; 544 | int16_t bar_day_spacing = bar_width; 545 | bar_width *= 7; 546 | 547 | bar_x = bar_start_x; 548 | drawRectangle(bar_x, bar_y, bar_width, bar_height); 549 | pr(bar_x, bar_y - 7, "Next 7 Days", fontMedium); 550 | 551 | int count = 0; 552 | JsonObject daily = doc["daily"]; 553 | for (JsonObject daily_data_item : daily["data"].as()) { 554 | ink.drawThickLine(bar_x, bar_y, bar_x, bar_y + bar_height, 0, 1); 555 | 556 | const char* icon = daily_data_item["icon"]; 557 | centreIcon(bar_x + bar_day_spacing / 2, bar_y + bar_height / 2, icon, SMALL_IMAGE); 558 | 559 | uint32_t when = daily_data_item["time"]; 560 | centre(bar_x + bar_day_spacing / 2, bar_y + bar_height + 22, day(when, offset), fontMedium); 561 | 562 | char high[TEMP_SIZE]; 563 | roundTemp(daily_data_item["temperatureHigh"], &high[0]); 564 | char low[TEMP_SIZE]; 565 | roundTemp(daily_data_item["temperatureLow"], &low[0]); 566 | 567 | // The reason the degree symbol is printed separately from the actual temperature is 568 | // that this causes the temperature digits to be centered and looks better. If the 569 | // symbol is included in the centred string it doesn't look as good on screen. 570 | 571 | centre(bar_x + bar_day_spacing / 2, bar_y + 21, high, fontMedium); 572 | ink.print("\xba"); 573 | centre(bar_x + bar_day_spacing / 2, bar_y + bar_height - 5, low, fontMedium); 574 | ink.print("\xba"); 575 | 576 | bar_x += bar_day_spacing; 577 | count += 1; 578 | if (count == 7) { 579 | break; 580 | } 581 | } 582 | 583 | // Step 5. 584 | // 585 | // Now make another API call to just get the minute-by-minute rain for the current 586 | // hour and draw that on screen. 587 | 588 | response = callAPI("daily,hourly,alerts", 0); 589 | if (response == "") { 590 | return; 591 | } 592 | 593 | doc.clear(); 594 | err = deserializeJson(doc, response); 595 | 596 | if (err) { 597 | fatal("Deserialize JSON failed " + String(err.c_str())); 598 | return; 599 | } 600 | 601 | int16_t bar_spacing = 50; 602 | int16_t new_width = bar_width - bar_spacing; 603 | new_width /= 60; 604 | new_width *= 60; 605 | bar_x = bar_start_x + bar_width + bar_spacing + (bar_width - new_width - bar_spacing); 606 | bar_width = new_width; 607 | int16_t rain_width = bar_width / 60; 608 | drawRectangle(bar_x, bar_y, bar_width, bar_height); 609 | pr(bar_x, bar_y - 6, "Rain Next 60 Minutes", fontMedium); 610 | 611 | float max_rain = 4; 612 | 613 | count = 0; 614 | int16_t centre_x; 615 | JsonObject minutely = doc["minutely"]; 616 | for (JsonObject minutely_data_item : minutely["data"].as()) { 617 | float rain = minutely_data_item["precipIntensity"]; 618 | if (rain > max_rain) { 619 | rain = max_rain; 620 | } 621 | 622 | if (rain > 0) { 623 | ink.drawThickLine(bar_x, bar_y, bar_x, bar_y + (bar_height * (rain / max_rain)), 0, 1); 624 | } 625 | 626 | if ((count % 10) == 0) { 627 | int when = minutely_data_item["time"]; 628 | char temp[TEMP_SIZE]; 629 | hhmm(when, offset, &temp[0]); 630 | if (count == 0) { 631 | pr(bar_x, bar_y + bar_height + short_tick + 11, temp, fontSmall); 632 | } else if (count == 60) { 633 | right(bar_x, bar_y + bar_height + short_tick + 11, temp, fontSmall); 634 | } else { 635 | centre(bar_x, bar_y + bar_height + short_tick + 11, temp, fontSmall); 636 | } 637 | if (count == 30) { 638 | centre_x = bar_x; 639 | } 640 | ink.drawThickLine(bar_x, bar_y + bar_height, bar_x, bar_y + bar_height + short_tick, 0, 1); 641 | } 642 | 643 | count += 1; 644 | bar_x += rain_width; 645 | } 646 | 647 | // Step 6. 648 | // 649 | // Using data about the current forecast show the title and when the forecast 650 | // was last checked and local observations (weather and temperature). 651 | 652 | JsonObject currently = doc["currently"]; 653 | const char *icon = currently["icon"]; 654 | float c = currently["temperature"]; 655 | uint32_t when = currently["time"]; 656 | 657 | char temp[TEMP_SIZE]; 658 | roundTemp(c, &temp[0]); 659 | pr(200, 80, title, fontLarge); 660 | char hm[HHMM_SIZE]; 661 | hhmm(update_time, offset, &hm[0]); 662 | char subtitle[80]; 663 | sprintf(subtitle, "Forecast checked at %s. It's %s\xba right now.", hm, temp); 664 | 665 | pr(200, 150, subtitle, fontLarge); 666 | centreIcon(bar_start_x + 64, 100, icon, LARGE_IMAGE); 667 | 668 | // Step 7. 669 | // 670 | // Add the status bar at the bottom 671 | 672 | char hmnow[HHMM_SIZE]; 673 | hhmm(update_time, offset, &hmnow[0]); 674 | char hmnext[HHMM_SIZE]; 675 | hhmm(next, offset, &hmnext[0]); 676 | 677 | JsonObject flags = doc["flags"]; 678 | const char *version = flags["version"]; 679 | 680 | char status_bar[255]; 681 | sprintf(status_bar, "Updated: %s - Next update: %s - Time zone: %s (%.1f) - Temperature: %d\xba - Battery: %.1fv - API %s", 682 | hmnow, hmnext, tz, offset, ink.readTemperature(), ink.readBattery(), version); 683 | centre(ink.width() / 2, ink.height() - 75, status_bar, fontSmall); 684 | 685 | show(); 686 | } 687 | 688 | // clear clears the display 689 | void clear() { 690 | ink.clearDisplay(); 691 | ink.display(); 692 | } 693 | 694 | // show displays whatever has been written to the display on the 695 | // e-ink screen itself. 696 | void show() { 697 | ink.display(); 698 | } 699 | 700 | // fatal is used to show a fatal error on screen 701 | void fatal(String s) { 702 | clear(); 703 | ink.setTextColor(0, 7); 704 | ink.setTextSize(4); 705 | ink.setCursor(100, 100); 706 | ink.print(s); 707 | show(); 708 | } 709 | 710 | // deepSleep puts the device into deep sleep mode for sleep_time 711 | // seconds. When it wakes up setup() will be called. 712 | void deepSleep(uint64_t sleep_time) { 713 | 714 | // This is needed for Inkplate 10's that use the ESP32 WROVER-E 715 | // in order to reduce power consumption during sleep. 716 | 717 | rtc_gpio_isolate(GPIO_NUM_12); 718 | 719 | // The following sets the wake up timer to run after the appropriate 720 | // interval (in microseconds) and then goes to sleep. 721 | 722 | esp_sleep_enable_timer_wakeup(sleep_time * 1000000); 723 | esp_deep_sleep_start(); 724 | } 725 | --------------------------------------------------------------------------------