├── scripts ├── vardb │ ├── .rspec │ ├── variables.db │ ├── Gemfile │ ├── Gemfile.lock │ ├── spec │ │ ├── db_spec.rb │ │ └── spec_helper.rb │ └── variabledb.rb ├── upload_bitmaps.sh ├── print_bitmap.rb ├── travis │ └── prepare_release └── platformio │ ├── get_version.py │ ├── build_full_image.py │ └── build_web.py ├── test └── remote │ ├── .gitignore │ ├── .rspec │ ├── Gemfile │ ├── epaper_templates.env.example │ ├── Gemfile.lock │ ├── spec │ ├── variables_spec.rb │ └── spec_helper.rb │ └── lib │ └── api_client.rb ├── docs ├── bitmap_editor.png ├── bitmap_import.png ├── bitmap_index.png ├── fields_editor.png ├── formatter_editor.png ├── template_editor.png ├── variables_editor.png ├── fields_json_editor.png ├── mqtt_configuration.png ├── new_region_config.png └── visual_editor_sidebar.png ├── web ├── src │ ├── dashboard │ │ └── Dashboard.scss │ ├── templates │ │ ├── SelectionEditor.scss │ │ ├── SvgFieldEditor.scss │ │ ├── BadgedText.scss │ │ ├── template_updaters.js │ │ ├── BadgedText.jsx │ │ ├── ColorPicker.scss │ │ ├── ColorPicker.jsx │ │ ├── VisualTemplateEditor.scss │ │ ├── SvgCanvas.scss │ │ ├── VariableAutocompleteField.jsx │ │ ├── TemplateEditor.scss │ │ ├── ArrayFieldTemplate.jsx │ │ ├── LocationEditor.jsx │ │ ├── TemplatesIndex.jsx │ │ └── FormatterEditor.jsx │ ├── util │ │ ├── hash.js │ │ ├── MemoizedFontAwesomeIcon.jsx │ │ ├── SiteLoader.scss │ │ ├── SiteLoader.jsx │ │ ├── api.js │ │ ├── ErrorBoundary.jsx │ │ └── mungers.js │ ├── index.jsx │ ├── settings │ │ ├── CheckboxWidget.jsx │ │ ├── schema │ │ │ ├── web.js │ │ │ ├── index.js │ │ │ ├── system.js │ │ │ ├── display.js │ │ │ ├── mqtt.js │ │ │ ├── network.js │ │ │ ├── hardware.js │ │ │ └── power.js │ │ ├── SelectWidget.jsx │ │ ├── ui_schema.js │ │ └── SettingsForm.jsx │ ├── bitmaps │ │ ├── NewBitmapConfigurator.scss │ │ ├── BitmapEditor.scss │ │ ├── BitmapIndex.scss │ │ ├── BitmapToolbar.scss │ │ ├── NewBitmapConfigurator.jsx │ │ └── BitmapToolbar.jsx │ ├── variables │ │ └── VariablesIndex.scss │ ├── App.scss │ ├── NavBar.jsx │ ├── App.jsx │ └── state │ │ └── global_state.js ├── webpack.config.js ├── .neutrinorc.js ├── package.json ├── .gitignore └── util │ └── generate-cpp-asset-index.js ├── examples ├── alarm_clock │ ├── preview.png │ ├── sample.jpg │ ├── README.md │ ├── alarm_clock.json │ └── nodered_flow.json ├── weather_dashboard │ ├── 001-signs.bin │ ├── 004-rain.bin │ ├── 006-storm.bin │ ├── 009-snow.bin │ ├── 010-sun.bin │ ├── 011-moon.bin │ ├── preview.png │ ├── 003-clouds.bin │ ├── 008-cloudy.bin │ ├── 002-clouds-1.bin │ ├── 004-rain-32x32.bin │ ├── 005-night-rain.bin │ ├── 007-cloudy-1.bin │ ├── 009-snow-32x32.bin │ ├── 010-moon-32x32.bin │ ├── 010-sun-32x32.bin │ ├── 011-moon-32x32.bin │ ├── 001-signs-32x32.bin │ ├── 003-clouds-32x32.bin │ ├── 006-storm-32x32.bin │ ├── 008-cloudy-32x32.bin │ ├── 002-clouds-1-32x32.bin │ ├── 007-cloudy-1-32x32.bin │ ├── 005-night-rain-32x32.bin │ └── README.md └── README.md ├── lib ├── Display │ ├── FillStyle.h │ ├── FillStyle.cpp │ ├── BitmapRegion.h │ ├── DisplayTypeHelpers.h │ ├── TextRegion.h │ ├── BitmapRegion.cpp │ ├── RectangleRegion.h │ ├── Region.cpp │ ├── TextRegion.cpp │ ├── Region.h │ ├── RectangleRegion.cpp │ └── DisplayTemplateDriver.h ├── Variables │ ├── VariableFormatters.cpp │ ├── RatioVariableFormatter.cpp │ ├── RoundingVariableFormatter.cpp │ ├── VariableDictionary.h │ ├── CasesFormatter.cpp │ ├── TimeFormatter.cpp │ ├── PrintfFormatterString.cpp │ ├── PrintfFormatterNumeric.cpp │ ├── VariableDictionary.cpp │ ├── VariableFormatters.h │ └── VariableFormatterFactory.cpp ├── Util │ └── CharComparator.h ├── Settings │ ├── types │ │ ├── SleepMode.h │ │ └── SleepMode.cpp │ ├── EnvironmentConfig.h │ └── Settings.cpp ├── TokenParsing │ ├── TokenIterator.h │ ├── UrlTokenBindings.h │ ├── TokenIterator.cpp │ └── UrlTokenBindings.cpp ├── Time │ ├── Timezones.h │ └── Timezones.cpp ├── readme.txt ├── MQTT │ ├── MqttClient.h │ └── MqttClient.cpp ├── Database │ └── KeyValueDatabase.h └── HTTP │ └── EpaperWebServer.h ├── .gitignore ├── .clang-format ├── .travis.yml ├── LICENSE └── platformio.ini /scripts/vardb/.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /test/remote/.gitignore: -------------------------------------------------------------------------------- 1 | epaper_templates.env 2 | -------------------------------------------------------------------------------- /test/remote/.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /docs/bitmap_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/bitmap_editor.png -------------------------------------------------------------------------------- /docs/bitmap_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/bitmap_import.png -------------------------------------------------------------------------------- /docs/bitmap_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/bitmap_index.png -------------------------------------------------------------------------------- /docs/fields_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/fields_editor.png -------------------------------------------------------------------------------- /web/src/dashboard/Dashboard.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | .card { 3 | margin-bottom: 1em; 4 | } 5 | } -------------------------------------------------------------------------------- /docs/formatter_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/formatter_editor.png -------------------------------------------------------------------------------- /docs/template_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/template_editor.png -------------------------------------------------------------------------------- /docs/variables_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/variables_editor.png -------------------------------------------------------------------------------- /scripts/vardb/variables.db: -------------------------------------------------------------------------------- 1 | test5atest199est29est29999test39test4zzzzzzzzzzzzzztest2zzzzzz -------------------------------------------------------------------------------- /docs/fields_json_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/fields_json_editor.png -------------------------------------------------------------------------------- /docs/mqtt_configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/mqtt_configuration.png -------------------------------------------------------------------------------- /docs/new_region_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/new_region_config.png -------------------------------------------------------------------------------- /docs/visual_editor_sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/docs/visual_editor_sidebar.png -------------------------------------------------------------------------------- /examples/alarm_clock/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/alarm_clock/preview.png -------------------------------------------------------------------------------- /examples/alarm_clock/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/alarm_clock/sample.jpg -------------------------------------------------------------------------------- /web/src/templates/SelectionEditor.scss: -------------------------------------------------------------------------------- 1 | .selection-section { 2 | &:not(:first-child) { 3 | margin-top: 2em; 4 | } 5 | } -------------------------------------------------------------------------------- /examples/weather_dashboard/001-signs.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/001-signs.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/004-rain.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/004-rain.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/006-storm.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/006-storm.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/009-snow.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/009-snow.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/010-sun.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/010-sun.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/011-moon.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/011-moon.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/preview.png -------------------------------------------------------------------------------- /test/remote/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'multipart-post' 6 | gem 'rspec' 7 | -------------------------------------------------------------------------------- /examples/weather_dashboard/003-clouds.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/003-clouds.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/008-cloudy.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/008-cloudy.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/002-clouds-1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/002-clouds-1.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/004-rain-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/004-rain-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/005-night-rain.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/005-night-rain.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/007-cloudy-1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/007-cloudy-1.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/009-snow-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/009-snow-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/010-moon-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/010-moon-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/010-sun-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/010-sun-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/011-moon-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/011-moon-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/001-signs-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/001-signs-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/003-clouds-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/003-clouds-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/006-storm-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/006-storm-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/008-cloudy-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/008-cloudy-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/002-clouds-1-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/002-clouds-1-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/007-cloudy-1-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/007-cloudy-1-32x32.bin -------------------------------------------------------------------------------- /examples/weather_dashboard/005-night-rain-32x32.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidoh/epaper_templates/HEAD/examples/weather_dashboard/005-night-rain-32x32.bin -------------------------------------------------------------------------------- /web/src/util/hash.js: -------------------------------------------------------------------------------- 1 | export default function simpleHash(data, modValue = 0x10000) { 2 | return data.reduce((a, x) => (Math.imul(a, 31) + x), 0) % modValue; 3 | } -------------------------------------------------------------------------------- /web/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import App from './App'; 4 | 5 | render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /lib/Display/FillStyle.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #pragma once 4 | 5 | enum class FillStyle { 6 | OUTLINE, FILLED 7 | }; 8 | 9 | FillStyle fillStyleFromString(const String& str); -------------------------------------------------------------------------------- /web/src/util/MemoizedFontAwesomeIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | 4 | export default React.memo(FontAwesomeIcon); -------------------------------------------------------------------------------- /lib/Variables/VariableFormatters.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | String IdentityVariableFormatter::format(const String& value) const { 5 | return value; 6 | } 7 | -------------------------------------------------------------------------------- /scripts/upload_bitmaps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | server=$1 4 | shift 5 | 6 | for file in $@; do 7 | echo "Uploading ${file}..." 8 | curl -s "${server}/bitmaps" -F filename=@${file} 9 | done 10 | -------------------------------------------------------------------------------- /scripts/vardb/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 6 | 7 | gem "bindata" 8 | gem "rspec" 9 | gem "hexdump" -------------------------------------------------------------------------------- /lib/Display/FillStyle.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | FillStyle fillStyleFromString(const String& str) { 4 | if (str.equalsIgnoreCase("filled")) { 5 | return FillStyle::FILLED; 6 | } else { 7 | return FillStyle::OUTLINE; 8 | } 9 | } -------------------------------------------------------------------------------- /test/remote/epaper_templates.env.example: -------------------------------------------------------------------------------- 1 | EPAPER_TEMPLATES_HOSTNAME=epaper-templates 2 | 3 | EPAPER_TEMPLATES_MQTT_SERVER=my-mqtt-broker 4 | EPAPER_TEMPLATES_MQTT_USERNAME= 5 | EPAPER_TEMPLATES_MQTT_PASSWORD= 6 | EPAPER_TEMPLATES_MQTT_TOPIC_PREFIX=epaper_display_test/ -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Alarm clock 4 | 5 | 6 | 7 | ## Weather Dashboard 8 | 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pioenvs 2 | .piolibdeps 3 | .pio 4 | .clang_complete 5 | .gcc-flags.json 6 | data 7 | .vscode 8 | .vscode/c_cpp_properties.json 9 | /web/node_modules 10 | /web/build 11 | .vscode/.browse.c_cpp.db* 12 | .vscode/launch.json 13 | /dist 14 | *.directory 15 | *.code-workspace -------------------------------------------------------------------------------- /web/src/templates/SvgFieldEditor.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootstrap/scss/mixins/_breakpoints"; 4 | @import "~bootswatch/dist/darkly/bootswatch"; 5 | 6 | .alert.new-element { 7 | border: 1px dashed $success; 8 | } -------------------------------------------------------------------------------- /lib/Util/CharComparator.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #pragma once 4 | 5 | #ifndef _CHAR_COMPARATOR_H 6 | #define _CHAR_COMPARATOR_H 7 | 8 | struct cmp_str { 9 | bool operator()(char const *a, char const *b) const { 10 | return std::strcmp(a, b) < 0; 11 | } 12 | }; 13 | 14 | #endif -------------------------------------------------------------------------------- /web/src/templates/BadgedText.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootstrap/scss/mixins/_breakpoints"; 4 | @import "~bootswatch/dist/darkly/bootswatch"; 5 | 6 | .list-item-badge { 7 | min-width: 4em; 8 | margin-right: 0.5em; 9 | display: inline-block; 10 | color: $light; 11 | } -------------------------------------------------------------------------------- /web/src/templates/template_updaters.js: -------------------------------------------------------------------------------- 1 | export const onUpdateLocation = (onUpdateActive, dimension, amount) => { 2 | const fn = obj => { 3 | [`${dimension}`, `${dimension}1`, `${dimension}2`].forEach(d => { 4 | if (obj[d] != null) { 5 | obj[d] += amount; 6 | } 7 | }); 8 | }; 9 | 10 | onUpdateActive(fn); 11 | }; 12 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | // Whilst the configuration object can be modified here, the recommended way of making 2 | // changes is via the presets' options or Neutrino's API in `.neutrinorc.js` instead. 3 | // Neutrino's inspect feature can be used to view/export the generated configuration. 4 | const neutrino = require('neutrino'); 5 | 6 | module.exports = neutrino().webpack(); 7 | -------------------------------------------------------------------------------- /lib/Settings/types/SleepMode.h: -------------------------------------------------------------------------------- 1 | enum class SleepMode { 2 | ALWAYS_ON = 0, 3 | DEEP_SLEEP = 1 4 | }; 5 | 6 | #pragma once 7 | 8 | class SleepModeHelpers { 9 | public: 10 | static const char* SLEEP_MODE_NAMES[]; 11 | static const SleepMode DEFAULT_SLEEP_MODE = SleepMode::ALWAYS_ON; 12 | 13 | static const char* getName(const SleepMode sleepMode); 14 | static SleepMode parseName(const char* name); 15 | }; -------------------------------------------------------------------------------- /lib/Variables/RatioVariableFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | RatioVariableFormatter::RatioVariableFormatter(float baseValue) 4 | : baseValue(baseValue) {} 5 | 6 | String RatioVariableFormatter::format(const String& value) const { 7 | if (baseValue == 0.0) { 8 | return "0"; 9 | } else { 10 | float fValue = value.toFloat(); 11 | return String(fValue/baseValue); 12 | } 13 | } -------------------------------------------------------------------------------- /web/src/util/SiteLoader.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | .site-loader { 6 | svg { 7 | fill: theme-color("primary"); 8 | display: block; 9 | } 10 | 11 | &.lg svg { 12 | margin-top: 10em; 13 | margin-bottom: 10em; 14 | } 15 | 16 | svg { 17 | margin: 0 auto; 18 | } 19 | } -------------------------------------------------------------------------------- /lib/TokenParsing/TokenIterator.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _TOKEN_ITERATOR_H 4 | #define _TOKEN_ITERATOR_H 5 | 6 | class TokenIterator { 7 | public: 8 | TokenIterator(char* data, size_t length, char sep = ','); 9 | 10 | bool hasNext(); 11 | const char* nextToken(); 12 | void reset(); 13 | 14 | private: 15 | char* data; 16 | char* current; 17 | size_t length; 18 | char sep; 19 | int i; 20 | }; 21 | #endif 22 | -------------------------------------------------------------------------------- /web/src/templates/BadgedText.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Badge from "react-bootstrap/Badge"; 3 | import "./BadgedText.scss"; 4 | 5 | export function BadgedText({ badge, variant = "primary", children, ...props }) { 6 | return ( 7 | 8 | 9 | {badge} 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /web/src/util/SiteLoader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Loader from "react-loader-spinner"; 3 | 4 | import "./SiteLoader.scss"; 5 | 6 | export default ({ size = "lg" }) => ( 7 |
8 | 15 |
16 | ); 17 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: Google 2 | AlignOperands: false 3 | AlignConsecutiveAssignments: false 4 | AlignConsecutiveDeclarations: false 5 | AlignAfterOpenBracket: false 6 | AllowAllArgumentsOnNextLine: false 7 | AllowAllConstructorInitializersOnNextLine: true 8 | AllowAllParametersOfDeclarationOnNextLine: true 9 | BinPackParameters: false 10 | BinPackArguments: false 11 | BreakBeforeTernaryOperators: true 12 | BreakConstructorInitializers: BeforeComma -------------------------------------------------------------------------------- /lib/Variables/RoundingVariableFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | RoundingVariableFormatter::RoundingVariableFormatter(uint8_t digits) 4 | : digits(digits) 5 | { } 6 | 7 | String RoundingVariableFormatter::format(const String& value) const { 8 | char format[20]; 9 | sprintf(format, "%%.%df", digits); 10 | char formattedValue[value.length() + 1]; 11 | sprintf(formattedValue, format, value.toFloat()); 12 | 13 | return formattedValue; 14 | } -------------------------------------------------------------------------------- /lib/TokenParsing/UrlTokenBindings.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #ifndef _URL_TOKEN_BINDINGS_H 4 | #define _URL_TOKEN_BINDINGS_H 5 | 6 | class UrlTokenBindings { 7 | public: 8 | UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens); 9 | 10 | bool hasBinding(const char* key) const; 11 | const char* get(const char* key) const; 12 | 13 | private: 14 | TokenIterator& patternTokens; 15 | TokenIterator& requestTokens; 16 | }; 17 | 18 | #endif 19 | -------------------------------------------------------------------------------- /scripts/print_bitmap.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | =begin 4 | Prints a raw bitmap to the terminal in ASCII form 5 | =end 6 | 7 | width, height, file = ARGV 8 | data = File.read(file) 9 | width = width.to_i 10 | height = height.to_i 11 | 12 | r = 0 13 | 14 | data.bytes.each do |b| 15 | (0...8).each do |i| 16 | r += 1 17 | if ((b >> (7 - i)) & 1) == 1 18 | print "X " 19 | else 20 | print " " 21 | end 22 | if r == width 23 | puts 24 | r = 0 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /web/src/settings/CheckboxWidget.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import Switch from "react-switch"; 3 | 4 | export function CheckboxWidget({ value, onChange, label }) { 5 | let checked = value; 6 | 7 | if (typeof checked === "string") { 8 | checked = checked.toLowerCase() === "true"; 9 | } 10 | 11 | const _onChange = useCallback(value => onChange(value), [onChange]); 12 | 13 | return ( 14 | 18 | ) 19 | } -------------------------------------------------------------------------------- /web/src/templates/ColorPicker.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootstrap/scss/mixins/_breakpoints"; 4 | @import "~bootswatch/dist/darkly/bootswatch"; 5 | 6 | .color-picker { 7 | padding: 0.7em; 8 | background-color: lighten($dark, 8%); 9 | 10 | .color-choice { 11 | width: 30px; 12 | height: 30px; 13 | margin: 0 0.7em; 14 | cursor: pointer; 15 | 16 | &:first-child { 17 | margin-left: 0; 18 | } 19 | 20 | &.active { 21 | outline: 2px solid $success; 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /web/src/bitmaps/NewBitmapConfigurator.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | form.new-bitmap { 6 | max-width: 450px; 7 | margin: 0 auto; 8 | 9 | label.error { 10 | color: theme-color("danger"); 11 | font-weight: bold; 12 | 13 | .error-message { 14 | font-weight: normal; 15 | margin-left: 1em; 16 | } 17 | } 18 | } 19 | 20 | .dimensions-inputs { 21 | .input-group { 22 | &:not(:last-child) { 23 | margin-right: 2em; 24 | } 25 | 26 | max-width: 300px; 27 | } 28 | } -------------------------------------------------------------------------------- /lib/Time/Timezones.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #ifndef _TIMEZONES_H 5 | #define _TIMEZONES_H 6 | 7 | class TimezonesClass { 8 | public: 9 | TimezonesClass(); 10 | ~TimezonesClass(); 11 | 12 | static const char* DEFAULT_TIMEZONE_NAME; 13 | static Timezone& DEFAULT_TIMEZONE; 14 | 15 | bool hasTimezone(const String& tzName); 16 | Timezone& getTimezone(const String& tzName); 17 | String getTimezoneName(Timezone& tz); 18 | void setDefaultTimezone(Timezone& tz); 19 | 20 | private: 21 | std::map timezonesByName; 22 | Timezone* defaultTimezone; 23 | }; 24 | 25 | extern TimezonesClass Timezones; 26 | 27 | #endif 28 | -------------------------------------------------------------------------------- /test/remote/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | diff-lcs (1.3) 5 | multipart-post (2.1.1) 6 | rspec (3.9.0) 7 | rspec-core (~> 3.9.0) 8 | rspec-expectations (~> 3.9.0) 9 | rspec-mocks (~> 3.9.0) 10 | rspec-core (3.9.1) 11 | rspec-support (~> 3.9.1) 12 | rspec-expectations (3.9.1) 13 | diff-lcs (>= 1.2.0, < 2.0) 14 | rspec-support (~> 3.9.0) 15 | rspec-mocks (3.9.1) 16 | diff-lcs (>= 1.2.0, < 2.0) 17 | rspec-support (~> 3.9.0) 18 | rspec-support (3.9.2) 19 | 20 | PLATFORMS 21 | ruby 22 | 23 | DEPENDENCIES 24 | multipart-post 25 | rspec 26 | 27 | BUNDLED WITH 28 | 2.0.2 29 | -------------------------------------------------------------------------------- /web/src/templates/ColorPicker.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./ColorPicker.scss"; 4 | 5 | export const ColorPicker = ({ value, onChange, colors }) => { 6 | return ( 7 |
8 | {colors.map((color) => ( 9 |
onChange(color)} 13 | style={{ backgroundColor: color }} 14 | >
15 | ))} 16 |
17 | ); 18 | }; 19 | 20 | export const withColorPicker = ({ colors }) => { 21 | return ({ ...props }) => ; 22 | }; 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.7' 4 | sudo: false 5 | cache: 6 | directories: 7 | - "~/.pio" 8 | env: 9 | - NODE_VERSION="10" 10 | before_install: 11 | - nvm install $NODE_VERSION 12 | install: 13 | - pip3 install -U platformio 14 | - platformio lib install 15 | before_script: 16 | - export NODE_OPTIONS=–max_old_space_size=4096 17 | script: 18 | - platformio run 19 | before_deploy: 20 | - ./scripts/travis/prepare_release 21 | deploy: 22 | provider: releases 23 | prerelease: true 24 | api_key: $GITHUB_TOKEN 25 | file_glob: true 26 | skip_cleanup: true 27 | file: dist/*.bin 28 | on: 29 | repo: sidoh/epaper_templates 30 | tags: true 31 | -------------------------------------------------------------------------------- /web/src/settings/schema/web.js: -------------------------------------------------------------------------------- 1 | export default { 2 | key: "web", 3 | title: "Web", 4 | properties: { 5 | "web.admin_username": { 6 | $id: "#/properties/web.admin_username", 7 | type: "string", 8 | title: "Admin Username", 9 | default: "", 10 | examples: [""], 11 | pattern: "^(.*)$" 12 | }, 13 | "web.admin_password": { 14 | $id: "#/properties/web.admin_password", 15 | type: "string", 16 | title: "Admin Password", 17 | default: "", 18 | examples: [""], 19 | pattern: "^(.*)$" 20 | }, 21 | "web.port": { 22 | $id: "#/properties/web.port", 23 | type: "integer", 24 | title: "Port" 25 | } 26 | } 27 | }; -------------------------------------------------------------------------------- /lib/Settings/types/SleepMode.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | const char* SleepModeHelpers::SLEEP_MODE_NAMES[] = { 6 | "ALWAYS_ON", 7 | "DEEP_SLEEP" 8 | }; 9 | 10 | const char* SleepModeHelpers::getName(const SleepMode sleepMode) { 11 | return SLEEP_MODE_NAMES[static_cast(sleepMode)]; 12 | } 13 | 14 | SleepMode SleepModeHelpers::parseName(const char* name) { 15 | size_t index = 0; 16 | for (const char* storedName : SleepModeHelpers::SLEEP_MODE_NAMES) { 17 | if (0 == strcmp(name, storedName)) { 18 | return static_cast(index); 19 | } 20 | index++; 21 | } 22 | 23 | return SleepModeHelpers::DEFAULT_SLEEP_MODE; 24 | } -------------------------------------------------------------------------------- /scripts/vardb/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | bindata (2.4.10) 5 | diff-lcs (1.3) 6 | hexdump (0.2.3) 7 | rspec (3.9.0) 8 | rspec-core (~> 3.9.0) 9 | rspec-expectations (~> 3.9.0) 10 | rspec-mocks (~> 3.9.0) 11 | rspec-core (3.9.1) 12 | rspec-support (~> 3.9.1) 13 | rspec-expectations (3.9.1) 14 | diff-lcs (>= 1.2.0, < 2.0) 15 | rspec-support (~> 3.9.0) 16 | rspec-mocks (3.9.1) 17 | diff-lcs (>= 1.2.0, < 2.0) 18 | rspec-support (~> 3.9.0) 19 | rspec-support (3.9.2) 20 | 21 | PLATFORMS 22 | ruby 23 | 24 | DEPENDENCIES 25 | bindata 26 | hexdump 27 | rspec 28 | 29 | BUNDLED WITH 30 | 2.0.2 31 | -------------------------------------------------------------------------------- /lib/Display/BitmapRegion.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #ifndef _BITMAP_REGION 10 | #define _BITMAP_REGION 11 | 12 | class BitmapRegion : public Region { 13 | public: 14 | BitmapRegion( 15 | const String& variable, 16 | uint16_t x, 17 | uint16_t y, 18 | uint16_t w, 19 | uint16_t h, 20 | uint16_t color, 21 | uint16_t backgroundColor, 22 | std::shared_ptr formatter, 23 | uint16_t index 24 | ); 25 | ~BitmapRegion(); 26 | 27 | virtual void render(GxEPD2_GFX* display); 28 | private: 29 | uint16_t backgroundColor; 30 | }; 31 | 32 | #endif 33 | -------------------------------------------------------------------------------- /examples/alarm_clock/README.md: -------------------------------------------------------------------------------- 1 | # Alarm Clock 2 | 3 | I built a little bedside clock that displays: 4 | 5 | * The date and time 6 | * The number of hours I've slept (really, the number of hours that have passed since I last turned off my bedside lamps) 7 | * My sleep "progress" -- when full, I've slept for >=8h. 8 | 9 | #### Preview 10 | 11 | 12 | 13 | ### Setup 14 | 15 | The important variables to keep updated are: 16 | 17 | * `lights-out-time` - timestamp lights were turned out at. 18 | * `lights-out-time-relative` - number of hours/m that have passed since `lights-out-time`. 19 | * `sleep-time-percent` - % of 8h `lights-out-time-relative` is 20 | 21 | I keep these updated using a [Node-RED flow](./nodered_flow.json). -------------------------------------------------------------------------------- /web/src/util/api.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import axios from "axios"; 3 | import { ConcurrencyManager } from "axios-concurrency"; 4 | import useWebSocket from 'react-use-websocket'; 5 | 6 | const api = axios.create({ 7 | baseURL: "/api/v1" 8 | }); 9 | 10 | export const useEpaperWebsocket = () => { 11 | const options = useMemo(() => ({ 12 | shouldReconnect: (closeEvent) => true 13 | }), []); 14 | 15 | var loc = window.location, socketUrl; 16 | if (loc.protocol === "https:") { 17 | socketUrl = "wss:"; 18 | } else { 19 | socketUrl = "ws:"; 20 | } 21 | socketUrl += "//" + loc.host; 22 | socketUrl += "/socket"; 23 | 24 | return useWebSocket(socketUrl, options); 25 | } 26 | 27 | ConcurrencyManager(api, 1); 28 | 29 | export default api; -------------------------------------------------------------------------------- /lib/Variables/VariableDictionary.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | #ifndef VARIABLE_DICTIONARY 8 | #define VARIABLE_DICTIONARY 9 | 10 | class VariableDictionary { 11 | public: 12 | static const char FILENAME[]; 13 | static const size_t MAX_KEY_SIZE = 255; 14 | 15 | VariableDictionary(); 16 | 17 | String get(const String& key); 18 | void set(const String& key, const String& value); 19 | void erase(const String& key); 20 | void clear(); 21 | 22 | void save(); 23 | void load(); 24 | void loop(); 25 | 26 | private: 27 | KeyValueDatabase db; 28 | 29 | static const std::set TRANSIENT_VARIABLES; 30 | std::map transientVariables; 31 | }; 32 | 33 | #endif -------------------------------------------------------------------------------- /web/src/settings/SelectWidget.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useCallback } from "react"; 2 | import Form from "react-bootstrap/Form"; 3 | 4 | export function SelectWidget({ value, onChange, label, schema }) { 5 | const options = useMemo(() => { 6 | if (schema.enum) { 7 | return schema.enum.map(x => ({ label: x, value: x })); 8 | } else if (schema.oneOf) { 9 | return schema.oneOf.map(x => ({ label: x.title, value: x.const })); 10 | } 11 | }, [schema]); 12 | 13 | const _onChange = useCallback(e => onChange(e.target.value), [onChange]); 14 | 15 | return ( 16 | 17 | {options.map(x => ( 18 | 21 | ))} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /web/src/variables/VariablesIndex.scss: -------------------------------------------------------------------------------- 1 | .variables-form { 2 | .button-container { 3 | flex-basis: 20em; 4 | } 5 | 6 | .save-success { 7 | transition: .5s ease-out; 8 | display: inline-block; 9 | font-size: 1.3em; 10 | margin-left: 1em; 11 | 12 | &.hidden { 13 | opacity: 0%; 14 | } 15 | } 16 | 17 | .variable-row { 18 | margin: 1em 0; 19 | 20 | .variable-key { 21 | width: 30em; 22 | text-align: right; 23 | margin-right: 1em; 24 | } 25 | 26 | input { 27 | margin-right: 1em; 28 | } 29 | 30 | input.error { 31 | box-shadow: 0 0 10px red; 32 | } 33 | 34 | div.error { 35 | text-align: left; 36 | font-weight: bold; 37 | margin-bottom: 0.5em; 38 | } 39 | 40 | .flex-grow-1 { 41 | align-self: flex-end; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /web/src/settings/schema/index.js: -------------------------------------------------------------------------------- 1 | const createSchema = definition => ({ 2 | $schema: "http://json-schema.org/draft-07/schema#", 3 | $id: "https://sidoh.org/epaper-display/settings.json", 4 | type: "object", 5 | title: name, 6 | required: definition.required || [], 7 | properties: definition.properties, 8 | definitions: definition.definitions, 9 | dependencies: definition.dependencies 10 | }); 11 | 12 | import display from "./display"; 13 | import hardware from "./hardware"; 14 | import mqtt from "./mqtt"; 15 | import network from "./network"; 16 | import system from "./system"; 17 | import web from "./web"; 18 | import power from "./power"; 19 | 20 | const schemaBuilder = ({ displayTypes }) => { 21 | return [display(displayTypes), hardware, mqtt, network, power, system, web].map(x => ({ 22 | ...x, 23 | schema: createSchema(x) 24 | })); 25 | }; 26 | 27 | export default schemaBuilder; 28 | -------------------------------------------------------------------------------- /web/src/templates/VisualTemplateEditor.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootstrap/scss/mixins/_breakpoints"; 4 | @import "~bootswatch/dist/darkly/bootswatch"; 5 | 6 | .nav.nav-pills { 7 | .nav-item { 8 | .nav-link { 9 | padding: 0.5em 1em; 10 | } 11 | } 12 | } 13 | 14 | ul.block-list { 15 | list-style: none; 16 | padding: 0; 17 | margin: 0; 18 | 19 | li { 20 | padding: 0; 21 | margin: 0.3em 0; 22 | background-color: theme-color("secondary"); 23 | 24 | a { 25 | padding: 0.5em; 26 | } 27 | 28 | &.active { 29 | border-left: 3px solid lighten($primary, 20%); 30 | margin-left: -3px; 31 | } 32 | 33 | &:hover { 34 | background-color: lighten($secondary, 10%); 35 | } 36 | } 37 | } 38 | 39 | @include media-breakpoint-up(lg) { 40 | .scroll-pane { 41 | max-height: calc(100vh - 240px); 42 | overflow-y: auto; 43 | } 44 | } -------------------------------------------------------------------------------- /scripts/travis/prepare_release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | PROJECT_NAME="epaper_templates" 6 | 7 | prepare_log() { 8 | echo "[prepare release] -- $@" 9 | } 10 | 11 | if [ -z "$(git tag -l --points-at HEAD)" ]; then 12 | prepare_log "Skipping non-tagged commit." 13 | exit 0 14 | fi 15 | 16 | VERSION=$(git describe) 17 | 18 | prepare_log "Preparing release for tagged version: $VERSION" 19 | 20 | mkdir -p dist 21 | 22 | if [ -e ".pio/build" ]; then 23 | firmware_prefix=".pio/build" 24 | else 25 | firmware_prefix=".pioenvs" 26 | fi 27 | 28 | for file in $(ls ${firmware_prefix}/**/firmware.bin); do 29 | env_dir=$(dirname "$file") 30 | env=$(basename "$env_dir") 31 | 32 | cp "$file" "dist/${PROJECT_NAME}_${env}-${VERSION}.bin" 33 | done 34 | 35 | for file in $(ls ${firmware_prefix}/**/firmware-full.bin); do 36 | env_dir=$(dirname "$file") 37 | env=$(basename "$env_dir") 38 | 39 | cp "$file" "dist/INITIALIZER_${PROJECT_NAME}_${env}-${VERSION}.bin" 40 | done -------------------------------------------------------------------------------- /scripts/platformio/get_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | from subprocess import check_output 3 | import sys 4 | import os 5 | import platform 6 | import subprocess 7 | 8 | dir_path = os.path.dirname(os.path.realpath(__file__)) 9 | os.chdir(dir_path) 10 | 11 | # http://stackoverflow.com/questions/11210104/check-if-a-program-exists-from-a-python-script 12 | def is_tool(name): 13 | cmd = "where" if platform.system() == "Windows" else "which" 14 | try: 15 | check_output([cmd, "git"]) 16 | return True 17 | except: 18 | return False 19 | 20 | version = "UNKNOWN" 21 | 22 | if is_tool("git"): 23 | try: 24 | version = check_output(["git", "describe", "--always"]).rstrip() 25 | except: 26 | try: 27 | version = check_output(["git", "rev-parse", "--short", "HEAD"]).rstrip() 28 | except: 29 | pass 30 | pass 31 | 32 | sys.stdout.write("-DEPAPER_TEMPLATES_VERSION=%s %s" % (version.decode('utf-8'), ' '.join(sys.argv[1:]))) -------------------------------------------------------------------------------- /lib/Display/DisplayTypeHelpers.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | #include 10 | 11 | class DisplayTypeHelpers { 12 | public: 13 | 14 | static String displayTypeToString(GxEPD2::Panel type); 15 | static GxEPD2::Panel stringToDisplayType(const String& displayType); 16 | static bool is3Color(GxEPD2::Panel type); 17 | 18 | static GxEPD2_GFX* buildDisplay(GxEPD2::Panel type, uint8_t dcPin, uint8_t rstPin, uint8_t busyPin, uint8_t ssPin); 19 | 20 | static const GxEPD2::Panel DEFAULT_PANEL; 21 | static const char* DISPLAY_NAMES[]; 22 | 23 | static const std::map> PANEL_SIZES; 24 | static const std::map PANELS_BY_NAME; 25 | static const std::map PANEL_DESCRIPTIONS; 26 | static const std::map PANEL_COLOR_SUPPORT; 27 | }; -------------------------------------------------------------------------------- /lib/readme.txt: -------------------------------------------------------------------------------- 1 | 2 | This directory is intended for the project specific (private) libraries. 3 | PlatformIO will compile them to static libraries and link to executable file. 4 | 5 | The source code of each library should be placed in separate directory, like 6 | "lib/private_lib/[here are source files]". 7 | 8 | For example, see how can be organized `Foo` and `Bar` libraries: 9 | 10 | |--lib 11 | | |--Bar 12 | | | |--docs 13 | | | |--examples 14 | | | |--src 15 | | | |- Bar.c 16 | | | |- Bar.h 17 | | |--Foo 18 | | | |- Foo.c 19 | | | |- Foo.h 20 | | |- readme.txt --> THIS FILE 21 | |- platformio.ini 22 | |--src 23 | |- main.c 24 | 25 | Then in `src/main.c` you should use: 26 | 27 | #include 28 | #include 29 | 30 | // rest H/C/CPP code 31 | 32 | PlatformIO will find your libraries automatically, configure preprocessor's 33 | include paths and build them. 34 | 35 | More information about PlatformIO Library Dependency Finder 36 | - http://docs.platformio.org/page/librarymanager/ldf.html 37 | -------------------------------------------------------------------------------- /lib/TokenParsing/TokenIterator.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | TokenIterator::TokenIterator(char* data, size_t length, const char sep) 4 | : data(data), 5 | current(data), 6 | length(length), 7 | sep(sep), 8 | i(0) 9 | { 10 | for (size_t i = 0; i < length; i++) { 11 | if (data[i] == sep) { 12 | data[i] = 0; 13 | } 14 | } 15 | } 16 | 17 | const char* TokenIterator::nextToken() { 18 | if (i >= length) { 19 | return NULL; 20 | } 21 | 22 | char* token = current; 23 | char* nextToken = current; 24 | 25 | for (; i < length && *nextToken != 0; i++, nextToken++); 26 | 27 | if (i == length) { 28 | nextToken = NULL; 29 | } else { 30 | i = (nextToken - data); 31 | 32 | if (i < length) { 33 | nextToken++; 34 | } else { 35 | nextToken = NULL; 36 | } 37 | } 38 | 39 | current = nextToken; 40 | 41 | return token; 42 | } 43 | 44 | void TokenIterator::reset() { 45 | current = data; 46 | i = 0; 47 | } 48 | 49 | bool TokenIterator::hasNext() { 50 | return i < length; 51 | } 52 | -------------------------------------------------------------------------------- /web/src/App.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | .btn.btn-link { 6 | text-decoration: none; 7 | 8 | &:hover { 9 | text-decoration: none; 10 | filter: brightness(150%); 11 | } 12 | } 13 | 14 | .button-list { 15 | button, a { 16 | &:not(:last-child) { 17 | margin-right: 1em; 18 | } 19 | } 20 | } 21 | 22 | .navbar-top { 23 | .nav-link.active { 24 | font-weight: bold; 25 | } 26 | } 27 | 28 | .main-content { 29 | margin-top: 100px; 30 | } 31 | 32 | form.rjsf { 33 | .control-label { 34 | font-weight: bold; 35 | } 36 | 37 | .help-block { 38 | font-size: 0.8em; 39 | } 40 | 41 | .checkbox { 42 | input[type="checkbox"] { 43 | margin-right: 1em; 44 | } 45 | } 46 | } 47 | 48 | label.react-switch { 49 | display: flex; 50 | 51 | span { 52 | font-weight: bold; 53 | margin-right: 2em; 54 | margin-top: 0.5em; 55 | } 56 | } 57 | 58 | .react-select { 59 | color: #000; 60 | } -------------------------------------------------------------------------------- /lib/TokenParsing/UrlTokenBindings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | UrlTokenBindings::UrlTokenBindings(TokenIterator& patternTokens, TokenIterator& requestTokens) 4 | : patternTokens(patternTokens), 5 | requestTokens(requestTokens) 6 | { } 7 | 8 | bool UrlTokenBindings::hasBinding(const char* searchToken) const { 9 | patternTokens.reset(); 10 | while (patternTokens.hasNext()) { 11 | const char* token = patternTokens.nextToken(); 12 | 13 | if (token[0] == ':' && strcmp(token+1, searchToken) == 0) { 14 | return true; 15 | } 16 | } 17 | 18 | return false; 19 | } 20 | 21 | const char* UrlTokenBindings::get(const char* searchToken) const { 22 | patternTokens.reset(); 23 | requestTokens.reset(); 24 | 25 | while (patternTokens.hasNext() && requestTokens.hasNext()) { 26 | const char* token = patternTokens.nextToken(); 27 | const char* binding = requestTokens.nextToken(); 28 | 29 | if (token[0] == ':' && strcmp(token+1, searchToken) == 0) { 30 | return binding; 31 | } 32 | } 33 | 34 | return NULL; 35 | } 36 | -------------------------------------------------------------------------------- /web/src/settings/schema/system.js: -------------------------------------------------------------------------------- 1 | export default { 2 | key: "system", 3 | title: "System", 4 | properties: { 5 | "system.timezone": { 6 | $id: "#/properties/system.timezone", 7 | type: "string", 8 | oneOf: [ 9 | { const: "PT", title: "(GMT-07:00) Pacific Time" }, 10 | { const: "AZ", title: "(GMT-06:00) Arizona Time" }, 11 | { const: "MT", title: "(GMT-06:00) Mountain Time" }, 12 | { const: "CT", title: "(GMT-05:00) Central Time" }, 13 | { const: "ET", title: "(GMT-04:00) Eastern Time" }, 14 | { const: "UTC", title: "(GMT+00:00) Coordinated Universal Time" }, 15 | { const: "UK", title: "(GMT+00:00) Western Europe Time" }, 16 | { const: "CET", title: "(GMT+01:00) Central European Time" }, 17 | { const: "MSK", title: "(GMT+03:00) Moscow Standard Time" }, 18 | { const: "AUSET", title: "(GMT+10:00) Eastern Australia" }, 19 | ], 20 | title: "Timezone", 21 | default: "", 22 | examples: ["PT"], 23 | pattern: "^(.*)$", 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /examples/weather_dashboard/README.md: -------------------------------------------------------------------------------- 1 | # weather_dashboard 2 | 3 | A dashboard I keep above my desk that displays the following: 4 | 5 | * Current time and date 6 | * Current and 5-day weather forecast 7 | * Outside temperature as measured by my ESP8266-connected thermometer 8 | * Number of minutes until the next two [BART](https://bart.gov) trains arive at the station nearest to my home. 9 | 10 | #### Preview 11 | 12 | 13 | 14 | ### Setup 15 | 16 | The important variables to keep updated are: 17 | 18 | * `weather_icon` - defines large weather icon on the left. 19 | * `daily_weather_slot0_*` - defines the timestamps, icons, and temperatore for the 5-day forecast. 20 | * `outside_temp` - the temperature at the bottom left of the screen 21 | * `next_train` - BART times 22 | 23 | I'm glossing over some of these, but they're visible in the UI. 24 | 25 | I keep these updated using a [Node-RED flow](./nodered_flow.json). 26 | 27 | ### Acknowledgements 28 | 29 | * Icons made by [Freepik](https://www.flaticon.com/authors/freepik) on flaticon.com. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Chris Mullins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/src/settings/schema/display.js: -------------------------------------------------------------------------------- 1 | export default (displayTypes) => ({ 2 | key: "display", 3 | title: "Display", 4 | properties: { 5 | "display.display_type": { 6 | $id: "#/properties/display.display_type", 7 | oneOf: displayTypes.screens.map(x => ({ 8 | const: x.name, 9 | title: `${x.name} (${x.desc})` 10 | })), 11 | type: "string", 12 | title: "Display Type", 13 | default: "GDEW042T2", 14 | examples: ["GDEW042T2"] 15 | }, 16 | "display.full_refresh_period": { 17 | $id: "#/properties/display.full_refresh_period", 18 | type: "string", 19 | title: "Full Refresh Period (in milliseconds)", 20 | examples: ["36000000"], 21 | pattern: "^\\d+$" 22 | }, 23 | "display.template_name": { 24 | $id: "#/properties/display.template_name", 25 | type: "string", 26 | title: "Template", 27 | default: "", 28 | examples: [], 29 | pattern: "^(.*)$" 30 | }, 31 | "display.windowed_updates": { 32 | $id: "#/properties/display.windowed_updates", 33 | type: "boolean", 34 | title: "Windowed Updates", 35 | default: false 36 | } 37 | } 38 | }); -------------------------------------------------------------------------------- /lib/Variables/CasesFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | CasesVariableFormatter::CasesVariableFormatter(JsonObject args) { 4 | JsonVariant cases = args["cases"]; 5 | 6 | if (cases.is()) { 7 | for (JsonPair kv : cases.as()) { 8 | this->cases[kv.key().c_str()] = kv.value().as(); 9 | } 10 | } else if (cases.is()) { 11 | for (JsonVariant value : cases.as()) { 12 | JsonObject _value = value.as(); 13 | const char* mapFrom = _value["key"].as(); 14 | const char* mapTo = _value["value"].as(); 15 | 16 | this->cases[mapFrom] = mapTo; 17 | } 18 | } else { 19 | Serial.println( 20 | F("CasesVariableFormatter: ERROR - unexpected type for \"cases\" arg")); 21 | } 22 | 23 | this->defaultValue = args["default"].as(); 24 | this->prefix = args["prefix"].as(); 25 | } 26 | 27 | String CasesVariableFormatter::format(const String& value) const { 28 | String result = prefix; 29 | 30 | if (cases.count(value) > 0) { 31 | result += cases.at(value); 32 | } else { 33 | result += defaultValue; 34 | } 35 | 36 | return result; 37 | } 38 | -------------------------------------------------------------------------------- /lib/Display/TextRegion.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #ifndef _TEXT_REGION_H 10 | #define _TEXT_REGION_H 11 | 12 | class TextRegion : public Region { 13 | public: 14 | TextRegion( 15 | const String& variable, 16 | uint16_t x, 17 | uint16_t y, 18 | std::shared_ptr fixedBoundingBox, 19 | uint16_t color, 20 | uint16_t backgroundColor, 21 | const GFXfont* font, 22 | std::shared_ptr formatter, 23 | uint8_t size, 24 | uint16_t index 25 | ); 26 | ~TextRegion(); 27 | 28 | virtual void render(GxEPD2_GFX* display); 29 | virtual Rectangle getBoundingBox(); 30 | 31 | protected: 32 | const GFXfont* font; 33 | 34 | // Users can optionally manually specify a bounding rectangle. 35 | std::shared_ptr fixedBound; 36 | 37 | // Track current bounding box start coordinates separately from (x, y), which 38 | // is the position we set the cursor at. 39 | Rectangle currentBound; 40 | 41 | // Previous bounding box coordinates 42 | Rectangle previousBound; 43 | 44 | uint8_t size; 45 | uint16_t backgroundColor; 46 | }; 47 | 48 | #endif 49 | -------------------------------------------------------------------------------- /web/src/templates/SvgCanvas.scss: -------------------------------------------------------------------------------- 1 | .visual-editor { 2 | svg.template-canvas { 3 | &.selecting > * { 4 | cursor: crosshair !important; 5 | } 6 | 7 | &.cursor, &.cursor > *, &.cursor g > * { 8 | cursor: crosshair !important; 9 | } 10 | 11 | cursor: crosshair; 12 | 13 | .cursor-position { 14 | fill: none; 15 | cursor: none; 16 | stroke: #393; 17 | stroke-width: 1px; 18 | } 19 | 20 | image, text, line, rect { 21 | stroke-width: 1px; 22 | cursor: cell; 23 | user-select: none; 24 | 25 | &.active { 26 | fill-opacity: 0.8; 27 | z-index: 9; 28 | cursor: move; 29 | } 30 | 31 | &.creating { 32 | stroke-dasharray: 3 2; 33 | } 34 | } 35 | 36 | rect.selection-outline { 37 | fill: none; 38 | stroke: #666; 39 | stroke-width: 1px; 40 | stroke-dasharray: 3 2; 41 | } 42 | 43 | rect.outline { 44 | fill: none; 45 | stroke-width: 1px; 46 | } 47 | 48 | rect.filled, 49 | rect.active.filled { 50 | stroke-width: 0px; 51 | } 52 | 53 | rect.selection { 54 | fill: none; 55 | stroke: #99f; 56 | stroke-width: 1px; 57 | stroke-dasharray: 6 2; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /lib/Display/BitmapRegion.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | BitmapRegion::BitmapRegion(const String& variable, 6 | uint16_t x, 7 | uint16_t y, 8 | uint16_t w, 9 | uint16_t h, 10 | uint16_t color, 11 | uint16_t backgroundColor, 12 | std::shared_ptr formatter, 13 | uint16_t index) 14 | : Region(variable, {x, y, w, h}, color, formatter, "b-" + String(index)) 15 | , backgroundColor(backgroundColor) {} 16 | 17 | BitmapRegion::~BitmapRegion() {} 18 | 19 | void BitmapRegion::render(GxEPD2_GFX* display) { 20 | if (!SPIFFS.exists(variableValue)) { 21 | Serial.print(F("WARN - tried to render bitmap file that doesn't exist: ")); 22 | Serial.println(variableValue); 23 | } else { 24 | File file = SPIFFS.open(variableValue, "r"); 25 | size_t size = (boundingBox.w * boundingBox.h) / 8; 26 | uint8_t bits[size]; 27 | file.readBytes(reinterpret_cast(bits), size); 28 | 29 | file.close(); 30 | 31 | DisplayTemplateDriver::drawBitmap(display, 32 | bits, 33 | boundingBox.x, 34 | boundingBox.y, 35 | boundingBox.w, 36 | boundingBox.h, 37 | color, 38 | backgroundColor); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/Display/RectangleRegion.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | #pragma once 11 | 12 | class RectangleRegion : public Region { 13 | public: 14 | enum class DimensionType { STATIC, VARIABLE }; 15 | 16 | struct Dimension { 17 | DimensionType type; 18 | uint16_t value; 19 | 20 | uint16_t getValue(const String& variableValue) const; 21 | 22 | static bool hasVariable(JsonObject spec); 23 | static String extractVariable(JsonObject spec); 24 | static JsonObject extractFormatterDefinition(JsonObject spec); 25 | static Dimension fromSpec(JsonObject spec); 26 | }; 27 | 28 | RectangleRegion(const String& variable, 29 | uint16_t x, 30 | uint16_t y, 31 | Dimension width, 32 | Dimension height, 33 | uint16_t color, 34 | uint16_t background_color, 35 | std::shared_ptr formatter, 36 | FillStyle fillStyle, 37 | uint16_t index 38 | ); 39 | ~RectangleRegion(); 40 | 41 | virtual void render(GxEPD2_GFX* display); 42 | 43 | private: 44 | const FillStyle fillStyle; 45 | const Dimension w, h; 46 | Rectangle previousBoundingBox; 47 | uint16_t background_color; 48 | }; -------------------------------------------------------------------------------- /web/src/settings/schema/mqtt.js: -------------------------------------------------------------------------------- 1 | export default { 2 | key: "mqtt", 3 | title: "MQTT", 4 | properties: { 5 | "mqtt.server": { 6 | $id: "#/properties/mqtt.server", 7 | type: "string", 8 | title: "Server Hostname", 9 | default: "", 10 | examples: [], 11 | pattern: "^(.*)$" 12 | }, 13 | "mqtt.username": { 14 | $id: "#/properties/mqtt.username", 15 | type: "string", 16 | title: "Username", 17 | default: "", 18 | examples: [], 19 | pattern: "^(.*)$" 20 | }, 21 | "mqtt.password": { 22 | $id: "#/properties/mqtt.password", 23 | type: "string", 24 | title: "Password", 25 | default: "", 26 | examples: [""], 27 | pattern: "^(.*)$" 28 | }, 29 | "mqtt.variables_topic_pattern": { 30 | $id: "#/properties/mqtt.variables_topic_pattern", 31 | type: "string", 32 | title: "Variables Topic Pattern", 33 | examples: ["template-displays/my-display/variables/:variable_name"], 34 | pattern: "^(.*)$" 35 | }, 36 | "mqtt.client_status_topic": { 37 | $id: "#/properties/mqtt.client_status_topic", 38 | type: "string", 39 | title: "Client Status Topic", 40 | examples: ["template-displays/my-display/client_status"], 41 | pattern: "^(.*)$" 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /web/src/NavBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Nav from 'react-bootstrap/Nav' 3 | import NavbarBrand from 'react-bootstrap/NavbarBrand' 4 | import Navbar from 'react-bootstrap/Navbar' 5 | import Container from 'react-bootstrap/Container' 6 | import { 7 | BrowserRouter as Router, 8 | Switch, 9 | Route, 10 | Link, 11 | NavLink 12 | } from "react-router-dom"; 13 | 14 | export default (props) => ( 15 | 16 | 17 | 18 | E-Paper Templates 19 | 20 | 21 |
22 |
    23 | 24 | Templates 25 | 26 | 27 | 28 | Settings 29 | 30 | 31 | 32 | Variables 33 | 34 | 35 | 36 | Images 37 | 38 |
39 |
40 |
41 |
42 | ) -------------------------------------------------------------------------------- /web/src/templates/VariableAutocompleteField.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback } from "react"; 2 | import Form from "react-bootstrap/Form"; 3 | import { Typeahead } from "react-bootstrap-typeahead"; 4 | import useGlobalState from "../state/global_state"; 5 | 6 | export const VariableAutocompleteField = ({ 7 | onChange, 8 | schema, 9 | idSchema, 10 | formData 11 | }) => { 12 | const [globalState, globalActions] = useGlobalState(); 13 | 14 | useEffect(() => { 15 | globalActions.loadVariables(); 16 | }, []); 17 | 18 | const _onChange = useCallback( 19 | e => { 20 | const [first] = e; 21 | 22 | if (first) { 23 | if (typeof first === "string") { 24 | onChange(first); 25 | } else { 26 | onChange(first.label) 27 | } 28 | } 29 | }, 30 | [onChange] 31 | ); 32 | 33 | const _onInputChange = useCallback(v => onChange(v), [onChange]) 34 | 35 | const selected = formData ? [formData] : []; 36 | 37 | return ( 38 | 39 | {schema.title} 40 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /lib/Settings/EnvironmentConfig.h: -------------------------------------------------------------------------------- 1 | // Platform independent macro to generate a unique(ish) device ID 2 | #if defined(ESP8266) 3 | extern "C" { 4 | #include "user_interface.h" 5 | } 6 | #define ESP_CHIP_ID() (ESP.getChipId()) 7 | #elif defined(ESP32) 8 | #include 9 | #define ESP_CHIP_ID() (static_cast(ESP.getEfuseMac())) 10 | #endif 11 | 12 | #if defined(ESP8266) 13 | #include 14 | #include 15 | #include 16 | #elif defined(ESP32) 17 | #include 18 | #include 19 | extern "C" { 20 | #include "freertos/FreeRTOS.h" 21 | #include "freertos/timers.h" 22 | } 23 | #endif 24 | 25 | // ESP32 needs to include a SPIFFS library. ESP8266 has it baked into FS.h 26 | #if defined(ESP32) 27 | #include 28 | #endif 29 | 30 | #if defined(ESP8266) 31 | #define FILE_WRITE "w" 32 | #endif 33 | 34 | // Default pin configs 35 | #if defined(ESP8266) 36 | #define EPD_DEFAULT_DC_PIN D3 37 | #define EPD_DEFAULT_RST_PIN D4 38 | #define EPD_DEFAULT_BUSY_PIN 4 39 | #define EPD_DEFAULT_SLEEP_OVERRIDE_PIN D1 40 | #elif defined(ESP32) 41 | #define EPD_DEFAULT_SPI_BUS HSPI // HSPI == 2 | VSPI == 3 42 | #define EPD_DEFAULT_DC_PIN 17 43 | #define EPD_DEFAULT_RST_PIN 16 44 | #define EPD_DEFAULT_BUSY_PIN 7 45 | // Pins 34-39 don't have internal pull-up or pull-down resistors 46 | #define EPD_DEFAULT_SLEEP_OVERRIDE_PIN 25 47 | #endif 48 | -------------------------------------------------------------------------------- /lib/Variables/TimeFormatter.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | static const char FORMAT_ARG_NAME[] = "format"; 8 | static const char TIMEZONE_ARG_NAME[] = "timezone"; 9 | 10 | const char TimeVariableFormatter::DEFAULT_TIME_FORMAT[] = "%H:%M"; 11 | 12 | TimeVariableFormatter::TimeVariableFormatter(const String& timeFormat, Timezone& timezone) 13 | : timeFormat(timeFormat), 14 | timezone(timezone) 15 | { } 16 | 17 | std::shared_ptr TimeVariableFormatter::build(JsonObject args) { 18 | Timezone& tz = Timezones.getTimezone(args[TIMEZONE_ARG_NAME]); 19 | String timeFormat; 20 | 21 | if (args.containsKey(FORMAT_ARG_NAME)) { 22 | timeFormat = args[FORMAT_ARG_NAME].as(); 23 | } else { 24 | timeFormat = DEFAULT_TIME_FORMAT; 25 | } 26 | 27 | return std::shared_ptr(new TimeVariableFormatter(timeFormat, tz)); 28 | } 29 | 30 | String TimeVariableFormatter::format(const String &value) const { 31 | time_t parsedTime = value.toInt(); 32 | parsedTime = timezone.toLocal(parsedTime); 33 | 34 | char buffer[100]; 35 | memset(buffer, 0, sizeof(buffer)); 36 | struct tm* tminfo = localtime(&parsedTime); 37 | 38 | strftime(buffer, sizeof(buffer), timeFormat.c_str(), tminfo); 39 | 40 | return buffer; 41 | } 42 | -------------------------------------------------------------------------------- /lib/Variables/PrintfFormatterString.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char FORMAT_ARG_NAME[] = "format"; 5 | 6 | PrintfFormatterString::PrintfFormatterString(const String& formatSchema) 7 | : formatSchema(formatSchema) 8 | { } 9 | 10 | std::shared_ptr PrintfFormatterString::build(JsonObject args) { 11 | String formatSchema; 12 | 13 | if (args.containsKey(FORMAT_ARG_NAME)) { 14 | formatSchema = args[FORMAT_ARG_NAME].as(); 15 | // Replacing the double percent in case the user needs a percent sign in the output 16 | // Replacing it with a (hopefully) impossible character should make sure that it doesn't get un-replaced by anything that is needed. 17 | formatSchema.replace("%%", "\a"); 18 | // This makes sure that if they add more than one formatter, it will only use the first and only argument. 19 | formatSchema.replace("%", "%1$"); 20 | // Undoing the first replace so the escaped percent can be printed 21 | formatSchema.replace("\a", "%%"); 22 | } else { 23 | formatSchema = "%1$s"; 24 | } 25 | 26 | return std::shared_ptr(new PrintfFormatterString(formatSchema)); 27 | } 28 | 29 | String PrintfFormatterString::format(const String &value) const { 30 | char buffer[120]; 31 | snprintf(buffer, sizeof(buffer), formatSchema.c_str(), value.c_str()); 32 | 33 | return buffer; 34 | } 35 | -------------------------------------------------------------------------------- /web/src/settings/schema/network.js: -------------------------------------------------------------------------------- 1 | export default { 2 | key: "network", 3 | title: "Network", 4 | properties: { 5 | "network.hostname": { 6 | $id: "#/properties/network.hostname", 7 | type: "string", 8 | title: "Hostname", 9 | examples: ["epaper-display"], 10 | pattern: "^(.*)$" 11 | }, 12 | "network.ntp_server": { 13 | $id: "#/properties/network.ntp_server", 14 | type: "string", 15 | title: "NTP Server", 16 | default: "pool.ntp.org", 17 | examples: ["pool.ntp.org"], 18 | pattern: "^(.*)$" 19 | }, 20 | "network.mdns_name": { 21 | $id: "#/properties/network.mdns_name", 22 | type: "string", 23 | title: "mDNS Name", 24 | default: "", 25 | examples: ["epaper-display"], 26 | pattern: "^(.*)$" 27 | }, 28 | "network.setup_ap_password": { 29 | $id: "#/properties/network.setup_ap_password", 30 | type: "string", 31 | title: "Setup AP Password", 32 | default: "", 33 | examples: ["waveshare"], 34 | pattern: "^(.*)$" 35 | }, 36 | "network.wifi_ssid": { 37 | $id: "#/properties/network.wifi_ssid", 38 | type: "string", 39 | title: "WiFi SSID", 40 | default: "", 41 | examples: [""], 42 | pattern: "^(.*)$" 43 | }, 44 | "network.wifi_password": { 45 | $id: "#/properties/network.wifi_password", 46 | type: "string", 47 | title: "WiFi Password", 48 | default: "", 49 | examples: [""], 50 | pattern: "^(.*)$" 51 | }, 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /web/src/settings/schema/hardware.js: -------------------------------------------------------------------------------- 1 | const VALID_PINS = [ 2 | 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39 3 | ]; 4 | 5 | export default { 6 | key: "hardware", 7 | title: "Hardware", 8 | definitions: { 9 | pin: { 10 | type: "integer", 11 | enum: VALID_PINS 12 | } 13 | }, 14 | properties: { 15 | "hardware.busy_pin": { 16 | $id: "#/properties/hardware.busy_pin", 17 | title: "Busy Pin", 18 | $ref: "#/definitions/pin", 19 | }, 20 | "hardware.dc_pin": { 21 | $id: "#/properties/hardware.dc_pin", 22 | title: "DC Pin", 23 | $ref: "#/definitions/pin" 24 | }, 25 | "hardware.rst_pin": { 26 | $id: "#/properties/hardware.rst_pin", 27 | title: "RST Pin", 28 | $ref: "#/definitions/pin" 29 | }, 30 | "hardware.spi_bus": { 31 | $id: "#/properties/hardware.spi_bus", 32 | title: "SPI Bus", 33 | oneOf: [ 34 | { const: "HSPI", title: "HSPI (default)" }, 35 | { const: "VSPI", title: "VSPI" }, 36 | { const: "waveshare", title: "Waveshare ESP32 Driver (custom)" }, 37 | ], 38 | type: "string", 39 | default: "HSPI" 40 | }, 41 | "hardware.ss_pin_override": { 42 | $id: "#/properties/hardware.ss_pin_override", 43 | title: "SPI SS Pin Override", 44 | oneOf: [ 45 | { const: -1, title: "default" }, 46 | ...VALID_PINS.map(x => ({const: x, title: String(x)})) 47 | ], 48 | type: "integer", 49 | default: -1 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /web/src/templates/TemplateEditor.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | .template-topnav.nav.nav-pills { 6 | margin-bottom: 1em; 7 | background-color: darken($secondary, 10%); 8 | 9 | .nav-item { 10 | margin: 0 0.5em; 11 | padding: 0.5em 0; 12 | font-weight: bold; 13 | 14 | &:first-child { 15 | margin-left: 1em; 16 | } 17 | 18 | .nav-link { 19 | border-radius: 0; 20 | padding: 0; 21 | 22 | &, &.active { 23 | background: transparent; 24 | } 25 | 26 | &.active { 27 | border-bottom: 3px solid lighten($primary, 20%); 28 | padding-bottom: 0.5em; 29 | margin-bottom: calc(-0.5em - 3px); 30 | } 31 | } 32 | } 33 | } 34 | 35 | .react-json-view { 36 | max-height: 400px; 37 | overflow: auto; 38 | } 39 | 40 | textarea.json-textarea { 41 | height: 400px; 42 | font-family: "Courier New", Courier, monospace; 43 | } 44 | 45 | .button-sidebar { 46 | width: 100%; 47 | min-width: 80px; 48 | background-color: darken($secondary, 10%); 49 | 50 | .spacer { 51 | flex-basis: 200px; 52 | flex-shrink: 1; 53 | } 54 | 55 | .gutter { 56 | flex-basis: 15px; 57 | flex-shrink: 1 58 | } 59 | 60 | button { 61 | height: 2.3em; 62 | width: 100%; 63 | min-width: 80px; 64 | text-align: left; 65 | display: inline-flex; 66 | 67 | svg { 68 | margin: auto 0; 69 | } 70 | 71 | span { 72 | margin: 0 auto; 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /web/.neutrinorc.js: -------------------------------------------------------------------------------- 1 | const react = require('@neutrinojs/react'); 2 | const styleMinify = require('@neutrinojs/style-minify') 3 | const GenerateHeaderFile = require("./util/generate-cpp-asset-index") 4 | const CompressionPlugin = require('compression-webpack-plugin') 5 | 6 | const API_SERVER_ADDRESS = '10.133.8.197' 7 | 8 | module.exports = { 9 | options: { 10 | root: __dirname, 11 | }, 12 | use: [ 13 | react({ 14 | html: { 15 | title: 'E-Paper Display' 16 | }, 17 | style: { 18 | test: /\.(css|scss)$/, 19 | loaders: ['sass-loader'] 20 | }, 21 | devServer: { 22 | historyApiFallback: { 23 | rewrites: [ 24 | { from: /^\/app\/.*$/, to: '/index.html' } 25 | ] 26 | }, 27 | proxy: { 28 | '/api': `http://${API_SERVER_ADDRESS}`, 29 | '/firmware': `http://${API_SERVER_ADDRESS}`, 30 | '/socket': { 31 | target: `ws://${API_SERVER_ADDRESS}`, 32 | ws: true 33 | } 34 | } 35 | } 36 | }), 37 | styleMinify, 38 | (neutrino) => neutrino.config 39 | .plugin('compress') 40 | .use(CompressionPlugin, [{ 41 | test: /\.(js|css|html)$/, 42 | 43 | // should in practice be <1, but use a large number to force-compress all assets. 44 | minRatio: 10, 45 | 46 | threshold: 0, 47 | }]) 48 | .after('optimize-css'), 49 | (neutrino) => neutrino.config 50 | .plugin('generate-header') 51 | .use(GenerateHeaderFile) 52 | .after('compress') 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /lib/Display/Region.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | Region::Region( 4 | const String& variable, 5 | Rectangle boundingBox, 6 | uint16_t color, 7 | std::shared_ptr formatter, 8 | String id 9 | ) : variable(variable) 10 | , boundingBox(boundingBox) 11 | , color(color) 12 | , formatter(formatter) 13 | , id(id) 14 | { } 15 | 16 | Region::~Region() { } 17 | 18 | const String& Region::getVariableName() const { 19 | return this->variable; 20 | } 21 | 22 | void Region::clearDirty() { 23 | this->dirty = false; 24 | } 25 | 26 | bool Region::isDirty() const { 27 | return this->dirty; 28 | } 29 | 30 | const String& Region::getVariableValue(const String& variable) { 31 | return this->variableValue; 32 | } 33 | 34 | bool Region::updateValue(const String &value) { 35 | String newValue = formatter->format(value); 36 | 37 | // No change 38 | if (newValue == variableValue) { 39 | return false; 40 | } 41 | 42 | Serial.printf_P(PSTR("Formatted value: %s\n"), newValue.c_str()); 43 | 44 | this->variableValue = newValue; 45 | this->dirty = true; 46 | 47 | return true; 48 | } 49 | 50 | Rectangle Region::updateScreen(GxEPD2_GFX* display) { 51 | Rectangle r = getBoundingBox(); 52 | display->refresh(r.x, r.y, r.w, r.h); 53 | return r; 54 | } 55 | 56 | Rectangle Region::getBoundingBox() { 57 | return boundingBox; 58 | } 59 | 60 | void Region::dumpResolvedDefinition(JsonArray r) { 61 | JsonArray arr = r.createNestedArray(); 62 | arr.add(variable); 63 | arr.add(this->variableValue); 64 | } 65 | 66 | String Region::getId() { 67 | return id; 68 | } -------------------------------------------------------------------------------- /scripts/platformio/build_full_image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from pathlib import Path 4 | 5 | Import("env") 6 | 7 | SOURCE_START_LOCATION = 0x10000 8 | 9 | 10 | def create_full_bin(source, target, env): 11 | firmware_file = target[0].get_abspath() 12 | full_image_filename = env.subst("$BUILD_DIR/${PROGNAME}-full.bin") 13 | parts = [] 14 | 15 | # Will contain 3 parts outside of the main firmware image: 16 | # * Bootloader 17 | # * Compiled partition table 18 | # * App loader 19 | for part in env.get("FLASH_EXTRA_IMAGES", []): 20 | start, filename = part 21 | 22 | # Parse hext string 23 | if isinstance(start, str): 24 | start = int(start, 16) 25 | 26 | filename = env.subst(filename) 27 | 28 | parts.append((start, filename)) 29 | 30 | # End with the main firmware image 31 | parts.append((SOURCE_START_LOCATION, firmware_file)) 32 | 33 | # Start at location of earliest image (don't start at 0x0) 34 | ix = parts[0][0] 35 | 36 | with open(full_image_filename, "wb") as f: 37 | while len(parts) > 0: 38 | part_ix, part_filename = parts.pop(0) 39 | part_filesize = Path(part_filename).stat().st_size 40 | padding = part_ix - ix 41 | 42 | for _ in range(padding): 43 | f.write(b"\x00") 44 | 45 | with open(part_filename, "rb") as part_file: 46 | f.write(part_file.read()) 47 | 48 | ix = part_ix + part_filesize 49 | 50 | 51 | env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", create_full_bin) 52 | -------------------------------------------------------------------------------- /web/src/bitmaps/BitmapEditor.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | canvas.image-editor { 6 | max-width: 600px; 7 | max-height: 600px; 8 | } 9 | 10 | .slider { 11 | width: 100%; 12 | height: 30px; 13 | 14 | .track { 15 | height: 10px; 16 | background-color: lighten(theme-color("secondary"), 10%); 17 | border-radius: 3px; 18 | } 19 | 20 | .thumb { 21 | height: 20px; 22 | width: 20px; 23 | cursor: pointer; 24 | border-radius: 10px; 25 | background-color: lighten(theme-color("primary"), 10%); 26 | top: -5px; 27 | } 28 | } 29 | 30 | .image-importer { 31 | .drop-zone { 32 | background-color: theme-color("light"); 33 | border: 1px dashed theme-color("primary"); 34 | font-style: italic; 35 | padding-top: 1em; 36 | text-align: center; 37 | height: 60px; 38 | margin-bottom: 0.5em; 39 | } 40 | 41 | .image-preview { 42 | .custom-file { 43 | margin: 0 1em; 44 | } 45 | 46 | canvas { 47 | max-width: 120px; 48 | max-height: 120px; 49 | } 50 | 51 | img { 52 | width: 120px; 53 | display: block; 54 | padding: 0.5em; 55 | border: 1px dashed theme-color("primary"); 56 | 57 | &:not(.has-image) { 58 | display: none; 59 | } 60 | } 61 | 62 | .metadata { 63 | margin: 0 auto; 64 | padding: 0.3em; 65 | border: 1px dashed theme-color("primary"); 66 | width: 100%; 67 | margin-left: 0.5em; 68 | font-size: 0.8em; 69 | color: $gray-600; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/Variables/PrintfFormatterNumeric.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static const char FORMAT_ARG_NAME[] = "format"; 5 | 6 | PrintfFormatterNumeric::PrintfFormatterNumeric(const String& formatSchema) 7 | : formatSchema(formatSchema) 8 | { } 9 | 10 | std::shared_ptr PrintfFormatterNumeric::build(JsonObject args) { 11 | String formatSchema; 12 | 13 | if (args.containsKey(FORMAT_ARG_NAME)) { 14 | formatSchema = args[FORMAT_ARG_NAME].as(); 15 | // In case someone does a schema that can crash the ESP 16 | formatSchema.replace("%s", "ERR"); 17 | // Replacing the double percent in case the user needs a percent sign in the output 18 | // Replacing it with a (hopefully) impossible character should make sure that it doesn't get un-replaced by anything that is needed. 19 | // \a is the bell/ding that can flash your console/cause it to make a sound. 20 | formatSchema.replace("%%", "\a"); 21 | // This makes sure that if they add more than one formatter, it will only use the first and only argument. 22 | formatSchema.replace("%", "%1$"); 23 | // Undoing the first replace so the escaped percent can be printed 24 | formatSchema.replace("\a", "%%"); 25 | } else { 26 | formatSchema = "%1$d"; 27 | } 28 | 29 | return std::shared_ptr(new PrintfFormatterNumeric(formatSchema)); 30 | } 31 | 32 | String PrintfFormatterNumeric::format(const String &value) const { 33 | int numericValue = value.toInt(); 34 | 35 | char buffer[120]; 36 | snprintf(buffer, sizeof(buffer), formatSchema.c_str(), numericValue); 37 | 38 | return buffer; 39 | } 40 | -------------------------------------------------------------------------------- /web/src/bitmaps/BitmapIndex.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | a.new-bitmap { 6 | display: block; 7 | width: 150px; 8 | height: 150px; 9 | border: 1px solid theme-color("secondary"); 10 | color: theme-color("primary"); 11 | margin: 1em; 12 | text-align: center; 13 | padding: 0.5em; 14 | 15 | &:hover { 16 | color: theme-color("primary"); 17 | background-color: darken(theme-color("secondary"), 10%); 18 | text-decoration: none; 19 | } 20 | 21 | svg { 22 | display: block; 23 | height: 100px !important; 24 | width: 100px !important; 25 | margin-bottom: 0.5em; 26 | } 27 | } 28 | 29 | ul.bitmap-list { 30 | list-style: none; 31 | padding: 0; 32 | margin: 0; 33 | display: flex; 34 | flex-wrap: wrap; 35 | 36 | .bitmap-link { 37 | text-decoration: none; 38 | } 39 | 40 | li { 41 | width: 150px; 42 | height: 150px; 43 | border: 1px solid theme-color("secondary"); 44 | padding: 0.5em; 45 | margin: 1em; 46 | 47 | &:hover { 48 | background-color: darken(theme-color("secondary"), 10%); 49 | } 50 | 51 | .filename { 52 | color: lighten(theme-color("secondary"), 20%); 53 | word-break: keep-all; 54 | overflow: hidden; 55 | width: 100%; 56 | display: block; 57 | padding: 0 1em; 58 | text-align: center; 59 | margin-top: 1em; 60 | font-size: 0.8em; 61 | text-overflow: ellipsis; 62 | white-space: nowrap; 63 | } 64 | } 65 | 66 | canvas.thumbnail-preview { 67 | max-width: 100%; 68 | max-height: calc(100% - 2em); 69 | 70 | margin: 0 auto; 71 | display: block; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /web/src/templates/ArrayFieldTemplate.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Container from "react-bootstrap/Container"; 3 | import Row from "react-bootstrap/Row"; 4 | import Col from "react-bootstrap/Col"; 5 | import Button from "react-bootstrap/Button"; 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; 8 | import MemoizedFontAwesomeIcon from "../util/MemoizedFontAwesomeIcon"; 9 | 10 | export function ArrayFieldTemplate(props) { 11 | const { title, items, onAddClick } = props; 12 | 13 | return ( 14 | 15 | {title && ( 16 | 17 | 18 |

{title}

19 | 20 |
21 | )} 22 | {items.map(x => ( 23 | 24 | 25 |
{x.children}
26 | 35 | 36 |
37 | ))} 38 | 39 | 40 | 41 | 50 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "epaper_web3", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack-dev-server --mode development --open", 9 | "build": "webpack --mode production" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@fortawesome/fontawesome-svg-core": "^1.2.28", 16 | "@fortawesome/free-regular-svg-icons": "^5.13.0", 17 | "@fortawesome/free-solid-svg-icons": "^5.13.0", 18 | "@fortawesome/react-fontawesome": "^0.1.9", 19 | "@neutrinojs/style-minify": "^8.3.0", 20 | "axios": "^0.21.1", 21 | "axios-concurrency": "^1.0.3", 22 | "base64-js": "^1.3.1", 23 | "bootstrap": "^4.4.1", 24 | "bootswatch": "^4.4.1", 25 | "compression-webpack-plugin": "^3.1.0", 26 | "crc": "^3.8.0", 27 | "deepmerge": "^4.2.2", 28 | "immer": "^8.0.1", 29 | "prop-types": "^15.7.2", 30 | "react": "^16.13.1", 31 | "react-bootstrap": "^1.0.0", 32 | "react-bootstrap-typeahead": "^3.4.7", 33 | "react-dom": "^16.13.1", 34 | "react-dropzone": "^10.2.2", 35 | "react-hot-loader": "^4.12.20", 36 | "react-json-view": "^1.19.1", 37 | "react-jsonschema-form": "^1.8.1", 38 | "react-loader-spinner": "^3.1.5", 39 | "react-router-dom": "^5.1.2", 40 | "react-slider": "^1.0.3", 41 | "react-switch": "^5.0.1", 42 | "react-use": "^13.27.1", 43 | "react-use-websocket": "^1.3.4", 44 | "serialize-javascript": "^3.1.0", 45 | "use-global-hook": "^0.1.12" 46 | }, 47 | "devDependencies": { 48 | "@neutrinojs/react": "^9.1.0", 49 | "neutrino": "^9.1.0", 50 | "node-sass": "^4.13.1", 51 | "sass-loader": "^8.0.2", 52 | "webpack": "^4.42.1", 53 | "webpack-cli": "^3.3.11", 54 | "webpack-dev-server": "^3.10.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/Variables/VariableDictionary.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | const std::set VariableDictionary::TRANSIENT_VARIABLES = { 5 | "timestamp" 6 | }; 7 | 8 | static const size_t MAX_VALUE_SIZE = 255; 9 | static const char DEFAULT_VALUE[] = ""; 10 | const char VariableDictionary::FILENAME[] = "/variables.db"; 11 | 12 | VariableDictionary::VariableDictionary() 13 | { } 14 | 15 | void VariableDictionary::set(const String& key, const String& value) { 16 | if (TRANSIENT_VARIABLES.find(key) != TRANSIENT_VARIABLES.end()) { 17 | transientVariables[key] = value; 18 | } else { 19 | db.set(key.c_str(), key.length(), value.c_str(), value.length()); 20 | } 21 | } 22 | 23 | void VariableDictionary::erase(const String &key) { 24 | db.erase(key.c_str(), key.length()); 25 | } 26 | 27 | String VariableDictionary::get(const String &key) { 28 | char valueBuffer[MAX_VALUE_SIZE]; 29 | auto tvLookup = transientVariables.find(key); 30 | 31 | if (tvLookup != transientVariables.end()) { 32 | return tvLookup->second; 33 | } else if (db.get(key.c_str(), key.length(), valueBuffer, MAX_VALUE_SIZE)) { 34 | return valueBuffer; 35 | } else { 36 | return DEFAULT_VALUE; 37 | } 38 | } 39 | 40 | void VariableDictionary::clear() { 41 | SPIFFS.remove(VariableDictionary::FILENAME); 42 | SPIFFS.open(VariableDictionary::FILENAME, "w").close(); 43 | 44 | db.open(SPIFFS.open(VariableDictionary::FILENAME, "r+")); 45 | db.initialize(); 46 | } 47 | 48 | void VariableDictionary::loop() { 49 | } 50 | 51 | void VariableDictionary::load() { 52 | // Create the database if it doesn't exist. 53 | // We specifically need r+ mode to enable both random reads and random writes. 54 | // r+ fails if the file doesn't already exist. 55 | if (! SPIFFS.exists(VariableDictionary::FILENAME)) { 56 | SPIFFS.open(VariableDictionary::FILENAME, "w").close(); 57 | } 58 | 59 | db.open(SPIFFS.open(VariableDictionary::FILENAME, "r+")); 60 | } 61 | 62 | void VariableDictionary::save() { 63 | } -------------------------------------------------------------------------------- /scripts/vardb/spec/db_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../variabledb" 2 | 3 | RSpec.describe Database do 4 | describe "#set" do 5 | it "Should store values without crashing" do 6 | Database.open_tmp do |db| 7 | db.set("a", "1") 8 | db.set("b", "2") 9 | end 10 | end 11 | end 12 | 13 | describe "#get" do 14 | it "Should have read-after-write consistency for simple values" do 15 | Database.open_tmp do |db| 16 | vals = { 17 | "a" => "1", 18 | "b" => "2", 19 | "c" => "3" 20 | } 21 | 22 | begin 23 | vals.each { |k, v| db.set(k, v) } 24 | vals.each do |k, v| 25 | expect(db.get(k)).to eq(v) 26 | end 27 | rescue StandardError => e 28 | db.dump 29 | raise e 30 | end 31 | end 32 | end 33 | 34 | it "Should allow us to overwrite values" do 35 | Database.open_tmp do |db| 36 | db.set("a", "1") 37 | db.set("a", "2") 38 | 39 | expect(db.get("a")).to eq("2") 40 | end 41 | end 42 | 43 | it "Should allow us to overwrite with different lengths" do 44 | Database.open_tmp do |db| 45 | begin 46 | db.set("a", "1") 47 | expect(db.get("a")).to eq("1") 48 | 49 | db.set("a", "1234") 50 | expect(db.get("a")).to eq("1234") 51 | 52 | lens = (1..5).to_a + (1..4).to_a.reverse 53 | lens.each do |l| 54 | val = "1"*l 55 | db.set("a", val) 56 | expect(db.get("a")).to eq(val) 57 | end 58 | 59 | db.set("b", "bb") 60 | db.set("c", "ccc") 61 | 62 | expect(db.get("b")).to eq("bb") 63 | expect(db.get("c")).to eq("ccc") 64 | 65 | db.set("c", "cccc") 66 | expect(db.get("c")).to eq("cccc") 67 | 68 | db.set("d", "ddd") 69 | expect(db.get("d")).to eq("ddd") 70 | expect(db.get("a")).to eq("1") 71 | 72 | db.dump 73 | rescue StandardError => e 74 | db.dump 75 | raise e 76 | end 77 | end 78 | end 79 | end 80 | end -------------------------------------------------------------------------------- /lib/MQTT/MqttClient.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #ifndef _MQTT_CLIENT_H 6 | #define _MQTT_CLIENT_H 7 | 8 | #define MQTT_TOPIC_VARIABLE_NAME_TOKEN "variable_name" 9 | 10 | #ifndef MQTT_CONNECTION_ATTEMPT_FREQUENCY 11 | #define MQTT_CONNECTION_ATTEMPT_FREQUENCY 5000 12 | #endif 13 | 14 | class MqttClient { 15 | public: 16 | typedef std::function TVariableUpdateFn; 17 | 18 | MqttClient(String domain, uint16_t port, String variableTopicPattern); 19 | MqttClient( 20 | String domain, 21 | uint16_t port, 22 | String variableTopicPattern, 23 | String username, 24 | String password, 25 | String clientStatusTopic 26 | ); 27 | ~MqttClient(); 28 | 29 | void begin(); 30 | void onVariableUpdate(TVariableUpdateFn fn); 31 | void updateStatus(const char* status); 32 | 33 | static const char* CONNECTED_STATUS; 34 | static const char* DISCONNECTED_STATUS; 35 | static const char* STATUS_VARIABLE; 36 | 37 | private: 38 | AsyncMqttClient mqttClient; 39 | #if defined(ESP8266) 40 | Ticker reconnectTimer; 41 | static void internalCallback(MqttClient* client); 42 | #elif defined(ESP32) 43 | TimerHandle_t reconnectTimer; 44 | static void internalCallback(TimerHandle_t xTimer); 45 | #endif 46 | 47 | uint16_t port; 48 | String domain; 49 | String username; 50 | String password; 51 | char clientName[30]; 52 | 53 | unsigned long lastConnectAttempt; 54 | TVariableUpdateFn variableUpdateCallback; 55 | 56 | // This will get reused a bunch. Allows us to avoid copying into a buffer 57 | // every time a message is received. 58 | String topicPattern; 59 | char* topicPatternBuffer; 60 | TokenIterator* topicPatternTokens; 61 | String clientStatusTopic; 62 | 63 | void connect(); 64 | 65 | void onWifiConnected(); 66 | void messageCallback( 67 | char* topic, 68 | char* payload, 69 | AsyncMqttClientMessageProperties properties, 70 | size_t len, 71 | size_t index, 72 | size_t total 73 | ); 74 | void connectCallback(bool sessionPresent); 75 | void disconnectCallback(AsyncMqttClientDisconnectReason reason); 76 | }; 77 | 78 | #endif 79 | -------------------------------------------------------------------------------- /web/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { hot } from "react-hot-loader"; 2 | import React, { useEffect } from "react"; 3 | import Container from "react-bootstrap/Container"; 4 | import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom"; 5 | 6 | import "./App.scss"; 7 | import NavBar from "./NavBar"; 8 | import SettingsForm from "./settings/SettingsForm"; 9 | import TemplatesIndex from "./templates/TemplatesIndex"; 10 | import VariablesIndex from "./variables/VariablesIndex"; 11 | import Dashboard from "./dashboard/Dashboard"; 12 | import BitmapsIndex from "./bitmaps/BitmapsIndex"; 13 | import useGlobalState from "./state/global_state"; 14 | import ErrorBoundary from "./util/ErrorBoundary"; 15 | import { Alert } from "react-bootstrap"; 16 | 17 | const App = () => { 18 | const [globalState, globalActions] = useGlobalState(); 19 | 20 | useEffect(() => { 21 | globalActions.loadInitialState(); 22 | }, []); 23 | 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | {globalState.errors.map((msg, i) => { 32 | return ( 33 | globalActions.dismissError(i)} 36 | dismissible 37 | > 38 | {msg} 39 | 40 | ); 41 | })} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | ); 64 | }; 65 | 66 | export default hot(module)(App); 67 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Neutrino build directory 107 | build -------------------------------------------------------------------------------- /lib/Settings/Settings.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #define PORT_POSITION(s) ( s.indexOf(':') ) 5 | 6 | String MqttSettings::serverHost() const { 7 | int pos = PORT_POSITION(server); 8 | 9 | if (pos == -1) { 10 | return server; 11 | } else { 12 | return server.substring(0, pos); 13 | } 14 | } 15 | 16 | uint16_t MqttSettings::serverPort() const { 17 | int pos = PORT_POSITION(server); 18 | 19 | if (pos == -1) { 20 | return DEFAULT_MQTT_PORT; 21 | } else { 22 | return atoi(server.c_str() + pos + 1); 23 | } 24 | } 25 | 26 | bool WebSettings::isAuthenticationEnabled() const { 27 | return admin_username.length() > 0 && admin_password.length() > 0; 28 | } 29 | 30 | const String& WebSettings::getUsername() const { 31 | return this->admin_username; 32 | } 33 | 34 | const String& WebSettings::getPassword() const { 35 | return this->admin_password; 36 | } 37 | 38 | void Settings::save() { 39 | Bleeper.storage.persist(); 40 | } 41 | 42 | void Settings::patch(JsonObject obj) { 43 | ConfigurationDictionary params; 44 | 45 | for (JsonObject::iterator it = obj.begin(); it != obj.end(); ++it) { 46 | params[it->key().c_str()] = it->value().as(); 47 | } 48 | 49 | this->setFromDictionary(params); 50 | this->save(); 51 | } 52 | 53 | void Settings::dump(Print& s) { 54 | DynamicJsonDocument json(4096); 55 | ConfigurationDictionary params = this->getAsDictionary(true); 56 | 57 | for (std::map::const_iterator it = params.begin(); it != params.end(); ++it) { 58 | json[it->first] = it->second; 59 | } 60 | 61 | serializeJson(json, s); 62 | } 63 | 64 | SettingsCallbackObserver::SettingsCallbackObserver(CallbackFn callback) 65 | : callback(callback) 66 | { } 67 | 68 | void SettingsCallbackObserver::onConfigurationChanged(const ConfigurationPropertyChange value) { 69 | if (this->callback) { 70 | this->callback(value); 71 | } 72 | } 73 | 74 | const uint8_t HardwareSettings::getSsPin() const { 75 | if (this->ss_pin_override != -1) { 76 | return static_cast(this->ss_pin_override); 77 | } 78 | 79 | switch (this->spi_bus) { 80 | case HSPI: 81 | case WAVESHARE_SPI: 82 | return 15; 83 | case VSPI: 84 | default: 85 | return 5; 86 | } 87 | } -------------------------------------------------------------------------------- /lib/Display/TextRegion.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #define BB_COORD(c, d) (static_cast(((c) >= 0) ? (c) : (d))) 4 | 5 | TextRegion::TextRegion( 6 | const String& variable, 7 | uint16_t x, 8 | uint16_t y, 9 | std::shared_ptr fixedBound, 10 | uint16_t color, 11 | uint16_t backgroundColor, 12 | const GFXfont* font, 13 | std::shared_ptr formatter, 14 | uint8_t size, 15 | uint16_t index 16 | ) : Region( 17 | variable, 18 | {x, y, 0, 0}, 19 | color, 20 | formatter, 21 | "t-" + String(index) 22 | ) 23 | , font(font) 24 | , fixedBound(fixedBound) 25 | , currentBound({x, y, 0, 0}) 26 | , previousBound({x, y, 0, 0}) 27 | , size(size) 28 | , backgroundColor(backgroundColor) 29 | { } 30 | 31 | TextRegion::~TextRegion() { } 32 | 33 | void TextRegion::render(GxEPD2_GFX* display) { 34 | // Clear the previous text 35 | // TODO: expose setting for background color 36 | display->fillRect( 37 | this->currentBound.x, 38 | this->currentBound.y, 39 | this->currentBound.w, 40 | this->currentBound.h, 41 | backgroundColor 42 | ); 43 | 44 | display->setTextColor(color); 45 | display->setFont(font); 46 | display->setTextSize(size); 47 | display->setCursor(this->boundingBox.x, this->boundingBox.y); 48 | display->print(variableValue); 49 | 50 | // Find and persist bounding box. Need to persist in case it shrinks next 51 | // time. Update should always be for the larger bounding box. 52 | int16_t x1, y1; 53 | uint16_t w, h; 54 | char valueCpy[variableValue.length() + 1]; 55 | strcpy(valueCpy, variableValue.c_str()); 56 | 57 | display->getTextBounds(valueCpy, this->boundingBox.x, this->boundingBox.y, &x1, &y1, &w, &h); 58 | 59 | this->previousBound = this->currentBound; 60 | this->currentBound = {x1, y1, w, h}; 61 | } 62 | 63 | Rectangle TextRegion::getBoundingBox() { 64 | if (fixedBound != nullptr) { 65 | return *fixedBound; 66 | } else { 67 | return { 68 | .x = std::min(this->currentBound.x, this->previousBound.x), 69 | .y = std::min(this->currentBound.y, this->previousBound.y), 70 | .w = std::max(this->currentBound.w, this->previousBound.w), 71 | .h = std::max(this->currentBound.h, this->previousBound.h) 72 | }; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /web/src/bitmaps/BitmapToolbar.scss: -------------------------------------------------------------------------------- 1 | @import "~bootswatch/dist/darkly/variables"; 2 | @import "~bootstrap/scss/bootstrap"; 3 | @import "~bootswatch/dist/darkly/bootswatch"; 4 | 5 | .bitmap-toolbar { 6 | border: 1px solid theme-color("light"); 7 | width: 100%; 8 | height: 40px; 9 | margin-bottom: 2em; 10 | 11 | .spacer { 12 | width: 15px; 13 | } 14 | 15 | ul.tool-list { 16 | list-style: none; 17 | padding: 0; 18 | 19 | li { 20 | display: inline-block; 21 | border-color: lighten(theme-color("secondary"), 15%); 22 | border-width: 0; 23 | border-style: solid; 24 | 25 | border-right-width: 1px; 26 | 27 | &:first-child { 28 | border-left-width: 1px; 29 | } 30 | } 31 | 32 | .btn { 33 | border-radius: 0; 34 | background-color: theme-color("secondary"); 35 | border-color: theme-color("secondary"); 36 | height: 38px; 37 | min-width: 38px; 38 | 39 | &.btn-success { 40 | background-color: darken(theme-color("success"), 10%); 41 | } 42 | 43 | &:hover { 44 | background-color: lighten(theme-color("secondary"), 10%); 45 | } 46 | 47 | &.active { 48 | background-color: lighten(theme-color("primary"), 25%); 49 | border-color: lighten(theme-color("primary"), 25%); 50 | } 51 | 52 | &.color-picker { 53 | &.white, 54 | &.white.active { 55 | background-color: #fff; 56 | } 57 | &.black, 58 | &.black.active { 59 | background-color: #000; 60 | } 61 | &.active { 62 | border: 3px solid theme-color("primary"); 63 | } 64 | } 65 | } 66 | } 67 | 68 | .inline-button { 69 | text-shadow: 0 0 10px; 70 | } 71 | 72 | label { 73 | background-color: lighten($body-bg, 10%); 74 | margin: 0; 75 | padding: 0 0.5em; 76 | height: 100%; 77 | line-height: 2.5em; 78 | font-weight: bold; 79 | font-size: 0.8em; 80 | line-height: 3em; 81 | color: lighten(theme-color("secondary"), 25%); 82 | } 83 | 84 | .dimension-editor { 85 | display: inline-flex; 86 | 87 | input { 88 | text-align: center; 89 | width: 4em; 90 | background-color: lighten($body-bg, 5%); 91 | border: 0; 92 | color: inherit; 93 | height: 100%; 94 | border-bottom: 1px dashed lighten(theme-color("secondary"), 20%); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /web/src/settings/ui_schema.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MemoizedFontAwesomeIcon from '../util/MemoizedFontAwesomeIcon' 3 | import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons' 4 | 5 | export default { 6 | "display.display_type": { 7 | "ui:help": "Model of the e-paper display you're using" 8 | }, 9 | "display.windowed_updates": { 10 | "ui:help": 11 | "When enabled, update partial regions of the screen. Only enable this if your display does not support partial updates.", 12 | transformer: x => x.toLowerCase() === "true" 13 | }, 14 | "power.sleep_mode": { 15 | "ui:help": ( 16 |
    17 |
  • 18 | Always On — Normal operation. System stays powered at all 19 | times 20 |
  • 21 |
  • 22 | Deep Sleep — Conserve power. System continuously boots 23 | for a configurable period waiting for updates, and then puts itself to 24 | sleep for a configurable period. 25 |
  • 26 |
27 | ) 28 | }, 29 | "power.sleep_override_pin": { 30 | "ui:help": 31 | "When this pin is held during boot, deep sleep will be disabled until the next restart.", 32 | transformer: parseInt 33 | }, 34 | "power.sleep_override_value": { 35 | "ui:help": "The value Sleep Override Pin must be held to in order to suspend deep sleep." 36 | }, 37 | "mqtt.password": { 38 | "ui:widget": "password" 39 | }, 40 | "web.admin_password": { 41 | "ui:widget": "password" 42 | }, 43 | "network.wifi_password": { 44 | "ui:widget": "password" 45 | }, 46 | "hardware.busy_pin": { 47 | transformer: parseInt 48 | }, 49 | "hardware.dc_pin": { 50 | transformer: parseInt 51 | }, 52 | "hardware.rst_pin": { 53 | transformer: parseInt 54 | }, 55 | "hardware.ss_pin_override": { 56 | transformer: parseInt, 57 | "ui:help": <> 58 |
59 | SPI bus to use. HSPI uses GPIOs 12, 14, 15. VSPI uses 5, 18, 19. See README for more details. 60 |
61 |
62 | 63 | Changing any of these settings requires a reboot! 64 |
65 | 66 | }, 67 | "hardware.spi_bus": { 68 | }, 69 | "web.port": { 70 | transformer: parseInt 71 | }, 72 | "mqtt.client_status_topic": { 73 | "ui:help": "If provided, MQTT birth and LWT messages will be published to this topic." 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /scripts/platformio/build_web.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | from shutil import copyfile 3 | from subprocess import check_output, CalledProcessError 4 | import sys 5 | import os 6 | import platform 7 | import subprocess 8 | from pathlib import Path 9 | 10 | def is_tool(name): 11 | cmd = "where" if platform.system() == "Windows" else "which" 12 | try: 13 | check_output([cmd, name]) 14 | return True 15 | except: 16 | return False 17 | 18 | def build_web(): 19 | if is_tool("npm"): 20 | os.chdir("web") 21 | print("Attempting to build webpage...") 22 | try: 23 | if platform.system() == "Windows": 24 | print(check_output(["npm.cmd", "install", "--only=dev"])) 25 | print(check_output(["npm.cmd", "run", "build"])) 26 | else: 27 | print(check_output(["npm", "install"])) 28 | print(check_output(["npm", "run", "build"])) 29 | 30 | if not os.path.exists("../dist"): 31 | os.mkdir("../dist") 32 | 33 | copyfile("build/web_assets.h", "../dist/web_assets.h") 34 | 35 | except BaseException as e: 36 | raise BaseException("Error building web assets: " + e) 37 | finally: 38 | os.chdir("..") 39 | else: 40 | print(""" 41 | [ERROR] Could not build web assets. 42 | 43 | npm is not installed. Please follow these instructions to install it: 44 | 45 | https://nodejs.org/en/download/package-manager/ 46 | """.strip()) 47 | 48 | raise BaseException("Could not build web assets. Please install nodejs.") 49 | 50 | def is_ignored(filename): 51 | ignored_paths = ["build/", ".cache/", "dist/"] 52 | if os.path.basename(filename).startswith(".") or os.path.isdir(filename): 53 | return True 54 | for check_path in ignored_paths: 55 | if check_path in filename: 56 | return True 57 | return False 58 | 59 | def should_build(): 60 | asset_path = "dist/web_assets.h" 61 | directory = "web/" 62 | if not os.path.exists(asset_path): 63 | return True 64 | else: 65 | timestamp = os.stat(asset_path).st_mtime 66 | 67 | for file in Path(directory).glob('**/*'): 68 | filename = str(file) 69 | if is_ignored(filename): 70 | continue 71 | if os.stat(filename).st_mtime > timestamp: 72 | print(filename+" was modified.") 73 | return True 74 | return False 75 | 76 | if should_build(): 77 | build_web() 78 | else: 79 | print("No need to rebuild web assets.") 80 | -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; http://docs.platformio.org/page/projectconf.html 10 | 11 | [common] 12 | framework = arduino 13 | board_f_cpu = 80000000L 14 | lib_deps_builtin = 15 | lib_deps_external = 16 | ArduinoJson@~6.17.1 17 | Adafruit GFX Library@~1.6.1 18 | Timezone@~1.2.2 19 | AsyncMqttClient@~0.8.2 20 | ESP Async WebServer@~1.2.0 21 | NTPClient@~3.1.0 22 | PathVariableHandlers@~2.0.0 23 | RichHttpServer@~2.0.0 24 | DNSServer@~1.1.0 25 | Bleeper@~1.0.4 26 | 27 | ; Gets rid of Time.h, which screws with case-sensitive filesystems. 28 | Time=https://github.com/xoseperez/Time#ecb2bb1 29 | 30 | ; Changes here prevent the redefinition of types that both AsyncWebServer 31 | ; and the webserver builtin to the espressif SDKs define. 32 | WiFiManager=https://github.com/sidoh/WiFiManager#async_support 33 | 34 | GxEPD2=https://github.com/ZinggJM/GxEPD2#1.2.14 35 | extra_scripts = 36 | pre:scripts/platformio/build_web.py 37 | post:scripts/platformio/build_full_image.py 38 | lib_ldf_mode = deep+ 39 | build_flags = !python3 scripts/platformio/get_version.py -DMQTT_DEBUG -Idist 40 | -D ENABLE_GxEPD2_GFX 41 | -D RICH_HTTP_ASYNC_WEBSERVER 42 | -D JSON_TEMPLATE_BUFFER_SIZE=20048 43 | -D RICH_HTTP_REQUEST_BUFFER_SIZE=20048 44 | -D RICH_HTTP_RESPONSE_BUFFER_SIZE=20048 45 | ; -D ASYNC_TCP_SSL_ENABLED 46 | ; -D CORE_DEBUG_LEVEL=ESP_LOG_DEBUG 47 | ; -D LOG_LOCAL_LEVEL=ESP_LOG_DEBUG 48 | 49 | ; [env:nodemcuv2] 50 | ; platform = espressif8266@~1.8 51 | ; framework = ${common.framework} 52 | ; board = nodemcuv2 53 | ; upload_speed = 921600 54 | ; build_flags = ${common.build_flags} -Wl,-Teagle.flash.4m1m.ld -D FIRMWARE_VARIANT=esp8266_nodemcuv2 55 | ; extra_scripts = ${common.extra_scripts} 56 | ; lib_ldf_mode = ${common.lib_ldf_mode} 57 | ; lib_deps = 58 | ; ${common.lib_deps_builtin} 59 | ; ${common.lib_deps_external} 60 | ; ESPAsyncTCP@~1.2.0 61 | ; lib_ignore = 62 | ; AsyncTCP 63 | 64 | [env:esp32] 65 | platform = espressif32@~1.9.0 66 | framework = ${common.framework} 67 | board = esp32doit-devkit-v1 68 | upload_speed = 460800 69 | build_flags = ${common.build_flags} -D FIRMWARE_VARIANT=esp32_doit 70 | extra_scripts = ${common.extra_scripts} 71 | lib_ldf_mode = ${common.lib_ldf_mode} 72 | board_build.partitions = min_spiffs.csv 73 | lib_deps = 74 | ${common.lib_deps_builtin} 75 | ${common.lib_deps_external} 76 | AsyncTCP@~1.1.1 77 | lib_ignore = 78 | ESPAsyncTCP 79 | -------------------------------------------------------------------------------- /lib/Display/Region.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | #ifndef _REGIONS_H 8 | #define _REGIONS_H 9 | 10 | struct Rectangle { 11 | uint16_t x, y, w, h; 12 | 13 | inline static uint16_t roundUp(uint16_t num, uint16_t base = 8) { 14 | return ((num + base - 1) / base) * base; 15 | } 16 | 17 | inline static uint16_t roundDown(uint16_t num, uint16_t base = 8) { 18 | if (num < base) { 19 | return 0; 20 | } 21 | return (num / base) * base; 22 | } 23 | 24 | // Since partial updates for displays are byte-aligned, we round to the nearest 25 | // multiple of 8: 26 | // 27 | // * For lower bounds, round down to the nearest multiple of 8 28 | // * For upper, round up. If the lower bounds were rounded down, stretch the 29 | // upper bounds by the same amount. 30 | Rectangle rounded() { 31 | uint16_t roundedX = Rectangle::roundDown(x); 32 | uint16_t roundedY = Rectangle::roundDown(y); 33 | 34 | // If the starting bound is rounded down, the corresponding length dimension 35 | // should be increased by the same amount the original value was rounded 36 | uint16_t roundedW = Rectangle::roundUp(w + (x - roundedX)); 37 | uint16_t roundedH = Rectangle::roundUp(h + (y - roundedY)); 38 | 39 | return { 40 | .x = roundedX, 41 | .y = roundedY, 42 | .w = roundedW, 43 | .h = roundedH 44 | }; 45 | } 46 | }; 47 | 48 | class Region { 49 | public: 50 | Region( 51 | const String& variable, 52 | Rectangle boundingBox, 53 | uint16_t color, 54 | std::shared_ptr formatter, 55 | String id 56 | ); 57 | ~Region(); 58 | 59 | virtual bool updateValue(const String& value); 60 | virtual void render(GxEPD2_GFX* display) = 0; 61 | 62 | // Returns the formatted value for the given variable. Presently regions only 63 | // support a single variable, but a future version should support multiple 64 | // variables per region, so we pass in the variable to resolve. 65 | virtual const String& getVariableValue(const String& variable); 66 | 67 | virtual Rectangle updateScreen(GxEPD2_GFX* display); 68 | virtual bool isDirty() const; 69 | virtual void clearDirty(); 70 | virtual const String& getVariableName() const; 71 | virtual Rectangle getBoundingBox(); 72 | virtual void dumpResolvedDefinition(JsonArray r); 73 | virtual String getId(); 74 | 75 | protected: 76 | const String variable; 77 | String variableValue; 78 | Rectangle boundingBox; 79 | uint16_t color; 80 | uint16_t background_color; 81 | bool dirty; 82 | std::shared_ptr formatter; 83 | String id; 84 | }; 85 | 86 | #endif 87 | -------------------------------------------------------------------------------- /test/remote/spec/variables_spec.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | RSpec.describe 'Variables' do 4 | before do 5 | @api = ApiClient.from_environment 6 | @api.delete('/variables') 7 | end 8 | 9 | context 'adding' do 10 | it 'should increment the size' do 11 | key = SecureRandom.hex(6) 12 | 13 | size = @api.get('/variables')['count'] 14 | @api.update_variables({key => 'value'}) 15 | 16 | expect(@api.get('/variables')['count']).to eq(size + 1) 17 | end 18 | end 19 | 20 | context 'updating' do 21 | it 'should reflect an updated value' do 22 | @api.update_variables(test_var1: 'test_value') 23 | 24 | expect(@api.get_variable('test_var1')).to eq('test_value') 25 | end 26 | 27 | it 'should reflect repeated updates' do 28 | (1..10).each do |val| 29 | @api.update_variables(test_var1: val) 30 | 31 | expect(@api.get_variable('test_var1')).to eq(val.to_s) 32 | end 33 | end 34 | 35 | it 'should reflect interlieved updates' do 36 | @api.update_variables(test_var1: '1', test_var2: '2') 37 | 38 | @api.update_variables(test_var1: '3') 39 | expect(@api.get_variable('test_var1')).to eq('3') 40 | expect(@api.get_variable('test_var2')).to eq('2') 41 | 42 | @api.update_variables(test_var2: 'zzz') 43 | expect(@api.get_variable('test_var1')).to eq('3') 44 | expect(@api.get_variable('test_var2')).to eq('zzz') 45 | end 46 | 47 | it 'should support shrinking and growing the value' do 48 | @api.update_variables(test_var1: 'X' * 20) 49 | 50 | expect(@api.get_variable('test_var1')).to eq('X' * 20) 51 | 52 | @api.update_variables(test_var1: 'Y') 53 | expect(@api.get_variable('test_var1')).to eq('Y') 54 | 55 | @api.update_variables(test_var1: 'XY' * 20) 56 | expect(@api.get_variable('test_var1')).to eq('XY' * 20) 57 | end 58 | end 59 | 60 | context 'deleting' do 61 | it 'should support deleting a variable' do 62 | @api.update_variables(test_var1: 'X') 63 | @api.delete('/variables/test_var1') 64 | expect(@api.get_variable('test_var1')).to eq(false) 65 | end 66 | 67 | it 'should support clearing the database' do 68 | @api.update_variables(test_var1: 'X') 69 | @api.delete('/variables') 70 | 71 | expect(@api.get('/variables')['count']).to eq(0) 72 | end 73 | 74 | it 'should affect the size' do 75 | key = SecureRandom.hex(6) 76 | 77 | size = @api.get('/variables')['count'] 78 | @api.update_variables({key => 'value'}) 79 | expect(@api.get('/variables')['count']).to eq(size + 1) 80 | @api.delete("/variables/#{key}") 81 | expect(@api.get('/variables')['count']).to eq(size) 82 | end 83 | end 84 | end -------------------------------------------------------------------------------- /web/src/templates/LocationEditor.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | faChevronDown, 3 | faChevronLeft, 4 | faChevronRight, 5 | faChevronUp 6 | } from "@fortawesome/free-solid-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import React, { useCallback, useState } from "react"; 9 | import ReactSlider from "react-slider"; 10 | import Button from "react-bootstrap/Button"; 11 | import Form from "react-bootstrap/Form"; 12 | import MemoizedFontAwesomeIcon from "../util/MemoizedFontAwesomeIcon"; 13 | import { onUpdateLocation } from "./template_updaters"; 14 | 15 | export function LocationEditor({ onUpdateActive }) { 16 | const [nudgeDistance, setNudgeDistance] = useState(1); 17 | const _onUpdateLocation = useCallback( 18 | onUpdateLocation.bind(this, onUpdateActive), 19 | [onUpdateActive] 20 | ); 21 | 22 | const onLeft = useCallback(() => _onUpdateLocation("x", -nudgeDistance), [ 23 | _onUpdateLocation, 24 | nudgeDistance 25 | ]); 26 | const onRight = useCallback(() => _onUpdateLocation("x", nudgeDistance), [ 27 | _onUpdateLocation, 28 | nudgeDistance 29 | ]); 30 | const onUp = useCallback(() => _onUpdateLocation("y", -nudgeDistance), [ 31 | _onUpdateLocation, 32 | nudgeDistance 33 | ]); 34 | const onDown = useCallback(() => _onUpdateLocation("y", nudgeDistance), [ 35 | _onUpdateLocation, 36 | nudgeDistance 37 | ]); 38 | 39 | return ( 40 | <> 41 |
Nudge
42 |
43 | 49 | 50 | {nudgeDistance}px 51 | 52 |
53 | 54 | 55 | 56 | 57 | 62 | 63 | 64 | 65 | 70 | 71 | 76 | 77 | 78 | 79 | 84 | 85 | 86 | 87 |
58 | 61 |
66 | 69 | 72 | 75 |
80 | 83 |
88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /web/util/generate-cpp-asset-index.js: -------------------------------------------------------------------------------- 1 | const PLUGIN_NAME = "GenerateCpAssetIndex" 2 | 3 | const buildCppAssetDefinition = (filename, source) => { 4 | const assetPath = `/${filename.replace(/.gz$/, "")}` 5 | const baseName = filename 6 | .replace(/[^a-zA-Z0-9]/g, '_') 7 | .toUpperCase() 8 | const sourceBytes = new Int16Array(source) 9 | 10 | const pathVar = `${baseName}_PATH` 11 | const pathDefinition = `static const char ${pathVar}[] = "${assetPath}";` 12 | 13 | const progmemSourceVar = baseName 14 | const progmemDefinition = `static const uint8_t ${progmemSourceVar}[] PROGMEM = { ${sourceBytes.join(",")} };` 15 | 16 | const lengthVar = `${baseName}_LENGTH` 17 | const lengthDefinition = `static const size_t ${lengthVar} = ${source.length};` 18 | 19 | return { 20 | progmemSourceVar, 21 | progmemDefinition, 22 | 23 | pathVar, 24 | pathDefinition, 25 | 26 | length: source.length, 27 | lengthVar, 28 | lengthDefinition, 29 | 30 | source, 31 | assetPath, 32 | extension: assetPath.split('.').slice(-1) 33 | } 34 | } 35 | 36 | const buildCppAssetIndex = (definitions) => { 37 | const contentTypesByExtension = { 38 | html: "text/html", 39 | js: "text/javascript", 40 | css: "text/css" 41 | } 42 | const source = `#include 43 | #include 44 | 45 | #ifndef _CHAR_COMPARATOR_H 46 | #define _CHAR_COMPARATOR_H 47 | struct cmp_str { 48 | bool operator()(char const *a, char const *b) const { 49 | return std::strcmp(a, b) < 0; 50 | } 51 | }; 52 | #endif 53 | 54 | ${definitions.map(x => x.pathDefinition).join("\n")} 55 | ${definitions.map(x => x.progmemDefinition).join("\n")} 56 | ${definitions.map(x => x.lengthDefinition).join("\n")} 57 | static const std::map WEB_ASSET_CONTENT_TYPES = {\n${ 58 | definitions 59 | .map(x => `{${x.pathVar},"${contentTypesByExtension[x.extension]}"}`) 60 | .join(",\n") 61 | }\n}; 62 | static const std::map WEB_ASSET_LENGTHS = {\n${ 63 | definitions 64 | .map(x => `{${x.pathVar},${x.lengthVar}}`) 65 | .join(",\n") 66 | }\n}; 67 | static const std::map WEB_ASSET_CONTENTS = {\n${ 68 | definitions 69 | .map(x => `{${x.pathVar},${x.progmemSourceVar}}`) 70 | .join(",\n") 71 | }\n}; 72 | ` 73 | return { 74 | source: () => source, 75 | size: () => source.length 76 | } 77 | } 78 | 79 | class GenerateCppAssetIndex { 80 | constructor() { } 81 | 82 | apply(compiler) { 83 | compiler.hooks.emit.tap(PLUGIN_NAME, compilation => { 84 | const assetDefns = Object.entries(compilation.assets) 85 | .filter(([filename]) => filename.endsWith(".gz")) 86 | .map(([filename, {_value: source}]) => buildCppAssetDefinition(filename, source)) 87 | 88 | compilation.assets['web_assets.h'] = buildCppAssetIndex(assetDefns) 89 | }) 90 | } 91 | } 92 | 93 | module.exports = GenerateCppAssetIndex; -------------------------------------------------------------------------------- /web/src/settings/schema/power.js: -------------------------------------------------------------------------------- 1 | export default { 2 | key: "power", 3 | title: "Power", 4 | definitions: { 5 | pin: { 6 | type: "integer", 7 | enum: [ 8 | 0, 9 | 1, 10 | 2, 11 | 3, 12 | 4, 13 | 5, 14 | 6, 15 | 7, 16 | 8, 17 | 9, 18 | 10, 19 | 11, 20 | 12, 21 | 13, 22 | 14, 23 | 15, 24 | 16, 25 | 17, 26 | 18, 27 | 19, 28 | 20, 29 | 21, 30 | 22, 31 | 23, 32 | 24, 33 | 25, 34 | 26, 35 | 27, 36 | 28, 37 | 29, 38 | 30, 39 | 31, 40 | 32, 41 | 33, 42 | 34, 43 | 35, 44 | 36, 45 | 37, 46 | 38, 47 | 39 48 | ] 49 | } 50 | }, 51 | properties: { 52 | "power.sleep_mode": { 53 | $id: "#/properties/network.sleep_mode", 54 | title: "Sleep Mode", 55 | oneOf: [ 56 | { const: "ALWAYS_ON", title: "Always On" }, 57 | { const: "DEEP_SLEEP", title: "Deep Sleep" } 58 | ], 59 | type: "string", 60 | default: "ALWAYS_ON" 61 | } 62 | }, 63 | required: ["power.sleep_mode"], 64 | dependencies: { 65 | "power.sleep_mode": { 66 | oneOf: [ 67 | { 68 | properties: { 69 | "power.sleep_mode": { 70 | enum: ["ALWAYS_ON"] 71 | } 72 | } 73 | }, 74 | { 75 | properties: { 76 | "power.sleep_mode": { 77 | enum: ["DEEP_SLEEP"] 78 | }, 79 | "power.sleep_duration": { 80 | $id: "#/properties/network.sleep_duration", 81 | title: "Sleep Duration (in seconds)", 82 | type: "string", 83 | pattern: "^(.*)$", 84 | default: "600" 85 | }, 86 | "power.awake_duration": { 87 | $id: "#/properties/network.awake_duration", 88 | title: "Awake Duration (in seconds)", 89 | type: "string", 90 | pattern: "^(.*)$", 91 | default: "30" 92 | }, 93 | "power.sleep_override_pin": { 94 | $id: "#/properties/network.sleep_override_pin", 95 | title: "Sleep Override Pin", 96 | $ref: "#/definitions/pin" 97 | }, 98 | "power.sleep_override_value": { 99 | $id: "#/properties/network.sleep_override_value", 100 | title: "Sleep Override Value", 101 | oneOf: [ 102 | { const: "1", title: "HIGH" }, 103 | { const: "0", title: "LOW" } 104 | ], 105 | type: "string", 106 | default: "1" 107 | } 108 | } 109 | } 110 | ] 111 | } 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /lib/Variables/VariableFormatters.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #ifndef _VARIABLE_FORMATTER_H 7 | #define _VARIABLE_FORMATTER_H 8 | 9 | class VariableFormatter { 10 | public: 11 | virtual String format(const String& value) const = 0; 12 | 13 | ~VariableFormatter() { } 14 | }; 15 | 16 | class IdentityVariableFormatter : public VariableFormatter { 17 | public: 18 | virtual String format(const String& value) const; 19 | }; 20 | 21 | class TimeVariableFormatter : public VariableFormatter { 22 | public: 23 | static const char DEFAULT_TIME_FORMAT[]; 24 | 25 | TimeVariableFormatter(const String& timeFormat, Timezone& timezone); 26 | 27 | virtual String format(const String& value) const; 28 | static std::shared_ptr build(JsonObject args); 29 | 30 | protected: 31 | String timeFormat; 32 | Timezone& timezone; 33 | }; 34 | 35 | class PrintfFormatterNumeric : public VariableFormatter { 36 | public: 37 | PrintfFormatterNumeric(const String& formatSchema); 38 | 39 | virtual String format(const String& value) const; 40 | static std::shared_ptr build(JsonObject args); 41 | 42 | protected: 43 | String formatSchema; 44 | }; 45 | 46 | class PrintfFormatterString : public VariableFormatter { 47 | public: 48 | PrintfFormatterString(const String& formatSchema); 49 | 50 | virtual String format(const String& value) const; 51 | static std::shared_ptr build(JsonObject args); 52 | 53 | protected: 54 | String formatSchema; 55 | }; 56 | 57 | class CasesVariableFormatter : public VariableFormatter { 58 | public: 59 | CasesVariableFormatter(JsonObject args); 60 | 61 | virtual String format(const String& value) const; 62 | 63 | protected: 64 | std::map cases; 65 | String defaultValue; 66 | String prefix; 67 | }; 68 | 69 | class RoundingVariableFormatter : public VariableFormatter { 70 | public: 71 | RoundingVariableFormatter(uint8_t digits); 72 | 73 | virtual String format(const String& value) const; 74 | private: 75 | uint8_t digits; 76 | }; 77 | 78 | class RatioVariableFormatter : public VariableFormatter { 79 | public: 80 | RatioVariableFormatter(float baseValue); 81 | 82 | virtual String format(const String& value) const; 83 | private: 84 | float baseValue; 85 | }; 86 | 87 | class VariableFormatterFactory { 88 | public: 89 | VariableFormatterFactory(const JsonVariant& referenceFormatters); 90 | 91 | std::shared_ptr create(JsonObject spec); 92 | 93 | private: 94 | std::map> refFormatters; 95 | 96 | std::shared_ptr getReference(String refKey, bool allowReference); 97 | std::shared_ptr _createInternal(JsonObject spec, bool allowReference); 98 | std::shared_ptr defaultFormatter; 99 | }; 100 | 101 | 102 | #endif 103 | -------------------------------------------------------------------------------- /lib/Display/RectangleRegion.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | RectangleRegion::RectangleRegion(const String& variable, 5 | uint16_t x, 6 | uint16_t y, 7 | RectangleRegion::Dimension w, 8 | RectangleRegion::Dimension h, 9 | uint16_t color, 10 | uint16_t background_color, 11 | std::shared_ptr formatter, 12 | FillStyle fillStyle, 13 | uint16_t index 14 | ) 15 | : Region(variable, {x, y, 0, 0}, color, formatter, "r-" + String(index)) 16 | , fillStyle(fillStyle) 17 | , w(w) 18 | , h(h) 19 | , previousBoundingBox({x, y, 0, 0}) 20 | , background_color(background_color) 21 | {} 22 | 23 | RectangleRegion::~RectangleRegion() {} 24 | 25 | void RectangleRegion::render(GxEPD2_GFX* display) { 26 | uint16_t width = w.getValue(variableValue); 27 | uint16_t height = h.getValue(variableValue); 28 | 29 | Serial.printf_P(PSTR("Drawing rectangle: (x=%d, y=%d, w=%d, h=%d)\n"), 30 | boundingBox.x, 31 | boundingBox.y, 32 | width, 33 | height); 34 | 35 | display->writeFillRect(boundingBox.x, 36 | boundingBox.y, 37 | boundingBox.w, 38 | boundingBox.h, 39 | background_color); 40 | 41 | if (fillStyle == FillStyle::FILLED) { 42 | display->writeFillRect(boundingBox.x, boundingBox.y, width, height, color); 43 | } else { 44 | display->drawRect(boundingBox.x, boundingBox.y, width, height, color); 45 | } 46 | 47 | this->boundingBox.w = std::max(this->previousBoundingBox.w, width); 48 | this->boundingBox.h = std::max(this->previousBoundingBox.h, height); 49 | this->previousBoundingBox = {boundingBox.x, boundingBox.y, width, height}; 50 | } 51 | 52 | uint16_t RectangleRegion::Dimension::getValue( 53 | const String& variableValue) const { 54 | if (this->type == RectangleRegion::DimensionType::STATIC) { 55 | return this->value; 56 | } else { 57 | return variableValue.toInt(); 58 | } 59 | } 60 | 61 | bool RectangleRegion::Dimension::hasVariable(JsonObject spec) { 62 | return spec["w"]["type"].as().equalsIgnoreCase("variable") || 63 | spec["h"]["type"].as().equalsIgnoreCase("variable"); 64 | } 65 | 66 | JsonObject RectangleRegion::Dimension::extractFormatterDefinition(JsonObject spec) { 67 | JsonVariant vw = spec["w"]["formatter"]; 68 | if (!vw.isNull()) { 69 | return vw.as(); 70 | } else { 71 | return spec["h"]["formatter"]; 72 | } 73 | } 74 | 75 | String RectangleRegion::Dimension::extractVariable(JsonObject spec) { 76 | JsonVariant vw = spec["w"]["variable"]; 77 | if (!vw.isNull()) { 78 | return vw.as(); 79 | } 80 | 81 | JsonVariant vh = spec["h"]["variable"]; 82 | if (!vh.isNull()) { 83 | return vh.as(); 84 | } 85 | 86 | return ""; 87 | } 88 | 89 | RectangleRegion::Dimension RectangleRegion::Dimension::fromSpec( 90 | JsonObject spec) { 91 | if (spec["type"].as().equalsIgnoreCase("variable")) { 92 | return {DimensionType::VARIABLE, 0}; 93 | } else { 94 | return {DimensionType::STATIC, spec["value"]}; 95 | } 96 | } -------------------------------------------------------------------------------- /web/src/bitmaps/NewBitmapConfigurator.jsx: -------------------------------------------------------------------------------- 1 | import { faPlusCircle } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import React, { useCallback, useState } from "react"; 4 | import Button from "react-bootstrap/Button"; 5 | import Form from "react-bootstrap/Form"; 6 | import InputGroup from "react-bootstrap/InputGroup"; 7 | import "./NewBitmapConfigurator.scss"; 8 | import MemoizedFontAwesomeIcon from "../util/MemoizedFontAwesomeIcon"; 9 | 10 | const LabelWithError = ({ label, error }) => { 11 | return ( 12 | 13 | {label} 14 | {error && {error}} 15 | 16 | ); 17 | }; 18 | 19 | export default ({ onSave }) => { 20 | const [filename, setFilename] = useState(""); 21 | const [width, setWidth] = useState(64); 22 | const [height, setHeight] = useState(64); 23 | const [errors, setErrors] = useState({}); 24 | 25 | const _onSave = useCallback( 26 | e => { 27 | e.preventDefault(); 28 | const newErrors = {}; 29 | 30 | if (!filename.match(/^[a-zA-Z0-9-._]+$/)) { 31 | newErrors.filename = 32 | "must be non-empty, and contain only a-z, 0-9, -, _, ."; 33 | } 34 | if (width == 0) { 35 | newErrors.dimensions = "must be > 0"; 36 | } 37 | if (height == 0) { 38 | newErrors.dimensions = "must be > 0"; 39 | } 40 | 41 | setErrors(newErrors); 42 | 43 | if (Object.keys(newErrors).length > 0) { 44 | return; 45 | } else { 46 | onSave({ filename, width, height }); 47 | } 48 | }, 49 | [onSave, filename, width, height] 50 | ); 51 | 52 | return ( 53 | <> 54 |
55 |

New Bitmap

56 |
57 | 58 | 59 | 60 | setFilename(e.target.value)} 63 | placeholder="my-cool-bitmap.bin" 64 | /> 65 | 66 | 67 | 68 | 69 | 70 |
71 | 72 | 73 | Width 74 | 75 | setWidth(e.target.value)} 79 | /> 80 | 81 | 82 | 83 | 84 | Height 85 | 86 | setHeight(e.target.value)} 90 | /> 91 | 92 |
93 |
94 | 95 | 96 |
97 |
98 | 102 |
103 | 104 | 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /lib/Time/Timezones.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | // https://github.com/JChristensen/Timezone/blob/master/examples/WorldClock/WorldClock.ino 4 | 5 | // Australia Eastern Time Zone (Sydney, Melbourne) 6 | TimeChangeRule aEDT = {"AEDT", First, Sun, Oct, 2, 660}; // UTC + 11 hours 7 | TimeChangeRule aEST = {"AEST", First, Sun, Apr, 3, 600}; // UTC + 10 hours 8 | Timezone ausET(aEDT, aEST); 9 | 10 | // Moscow Standard Time (MSK, does not observe DST) 11 | TimeChangeRule msk = {"MSK", Last, Sun, Mar, 1, 180}; 12 | Timezone tzMSK(msk); 13 | 14 | // Central European Time (Frankfurt, Paris) 15 | TimeChangeRule CEST = {"CEST", Last, Sun, Mar, 2, 120}; // Central European Summer Time 16 | TimeChangeRule CET = {"CET ", Last, Sun, Oct, 3, 60}; // Central European Standard Time 17 | Timezone CE(CEST, CET); 18 | 19 | // United Kingdom (London, Belfast) 20 | TimeChangeRule BST = {"BST", Last, Sun, Mar, 1, 60}; // British Summer Time 21 | TimeChangeRule GMT = {"GMT", Last, Sun, Oct, 2, 0}; // Standard Time 22 | Timezone UK(BST, GMT); 23 | 24 | // UTC 25 | TimeChangeRule utcRule = {"UTC", Last, Sun, Mar, 1, 0}; // UTC 26 | Timezone UTC(utcRule); 27 | 28 | // US Eastern Time Zone (New York, Detroit) 29 | TimeChangeRule usEDT = {"EDT", Second, Sun, Mar, 2, -240}; // Eastern Daylight Time = UTC - 4 hours 30 | TimeChangeRule usEST = {"EST", First, Sun, Nov, 2, -300}; // Eastern Standard Time = UTC - 5 hours 31 | Timezone usET(usEDT, usEST); 32 | 33 | // US Central Time Zone (Chicago, Houston) 34 | TimeChangeRule usCDT = {"CDT", Second, Sun, Mar, 2, -300}; 35 | TimeChangeRule usCST = {"CST", First, Sun, Nov, 2, -360}; 36 | Timezone usCT(usCDT, usCST); 37 | 38 | // US Mountain Time Zone (Denver, Salt Lake City) 39 | TimeChangeRule usMDT = {"MDT", Second, Sun, Mar, 2, -360}; 40 | TimeChangeRule usMST = {"MST", First, Sun, Nov, 2, -420}; 41 | Timezone usMT(usMDT, usMST); 42 | 43 | // Arizona is US Mountain Time Zone but does not use DST 44 | Timezone usAZ(usMST); 45 | 46 | // US Pacific Time Zone (Las Vegas, Los Angeles) 47 | TimeChangeRule usPDT = {"PDT", Second, Sun, Mar, 2, -420}; 48 | TimeChangeRule usPST = {"PST", First, Sun, Nov, 2, -480}; 49 | Timezone usPT(usPDT, usPST); 50 | 51 | Timezone& TimezonesClass::DEFAULT_TIMEZONE = usPT; 52 | const char* TimezonesClass::DEFAULT_TIMEZONE_NAME = "PT"; 53 | 54 | TimezonesClass::TimezonesClass() { 55 | timezonesByName["AUSET"] = &ausET; 56 | timezonesByName["MSK"] = &tzMSK; 57 | timezonesByName["CET"] = &CE; 58 | timezonesByName["UK"] = &UK; 59 | timezonesByName["UTC"] = &UTC; 60 | timezonesByName["ET"] = &usET; 61 | timezonesByName["CT"] = &usCT; 62 | timezonesByName["MT"] = &usMT; 63 | timezonesByName["AZ"] = &usAZ; 64 | timezonesByName["PT"] = &usPT; 65 | 66 | this->defaultTimezone = &DEFAULT_TIMEZONE; 67 | } 68 | 69 | TimezonesClass::~TimezonesClass() { } 70 | 71 | bool TimezonesClass::hasTimezone(const String& tzName) { 72 | return timezonesByName.count(tzName) > 0; 73 | } 74 | 75 | Timezone& TimezonesClass::getTimezone(const String& tzName) { 76 | if (hasTimezone(tzName)) { 77 | return *timezonesByName[tzName]; 78 | } 79 | 80 | Serial.println(F("WARN - couldn't find specified timezone. Returning default.")); 81 | 82 | return *this->defaultTimezone; 83 | } 84 | 85 | String TimezonesClass::getTimezoneName(Timezone &tz) { 86 | for (std::map::iterator itr = timezonesByName.begin(); itr != timezonesByName.end(); ++itr) { 87 | if (itr->second == &tz) { 88 | return itr->first; 89 | } 90 | } 91 | return ""; 92 | } 93 | 94 | void TimezonesClass::setDefaultTimezone(Timezone& tz) { 95 | this->defaultTimezone = &tz; 96 | } 97 | 98 | TimezonesClass Timezones; 99 | -------------------------------------------------------------------------------- /web/src/util/ErrorBoundary.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Alert from "react-bootstrap/Alert"; 3 | import Jumbotron from "react-bootstrap/Jumbotron"; 4 | import MemoizedFontAwesomeIcon from "./MemoizedFontAwesomeIcon"; 5 | import { 6 | faClipboard, 7 | faExclamationCircle, 8 | faCopy, 9 | faExternalLinkAlt, 10 | faRecycle 11 | } from "@fortawesome/free-solid-svg-icons"; 12 | import Button from "react-bootstrap/Button"; 13 | 14 | export default class ErrorBoundary extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = { error: null }; 18 | } 19 | 20 | componentDidCatch(error, info) { 21 | console.log(error, info); 22 | } 23 | 24 | static getDerivedStateFromError(error) { 25 | // Update state so the next render will show the fallback UI. 26 | return { error: error }; 27 | } 28 | 29 | onRefresh() { 30 | window.location.reload(); 31 | } 32 | 33 | render() { 34 | if (this.state.error) { 35 | console.log(this.state.error); 36 | const error_object = this.state.error; 37 | const errorDefn = { 38 | error: { 39 | fileName: error_object.fileName, 40 | lineNumber: error_object.lineNumber, 41 | message: error_object.message, 42 | stack: error_object.stack, 43 | columnNumber: error_object.columnNumber, 44 | name: error_object.name 45 | } 46 | }; 47 | 48 | return ( 49 | 50 |

51 | 55 | Encountered error. This is a bug! 56 |

57 | 58 |
59 |
60 | Click the button below to create an auto-filled Github issue. 61 | Please also include: 62 |
63 | 64 |
    65 |
  • What you did right before the error happened
  • 66 |
  • The template or data you were working with
  • 67 |
  • If possible, a reliable way to reproduce the issue
  • 68 |
69 | 70 |
71 | 79 | 83 | Create Github Issue 84 | 85 | 86 | 90 |
91 |
92 | 93 |
94 | 102 |
103 |
104 | ); 105 | } else { 106 | return <>{this.props.children}; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /examples/alarm_clock/alarm_clock.json: -------------------------------------------------------------------------------- 1 | { 2 | "rotation": 1, 3 | "lines": [ 4 | { 5 | "x1": 0, 6 | "y1": 55, 7 | "x2": 300, 8 | "y2": 55 9 | }, 10 | { 11 | "x1": 180, 12 | "y1": 0, 13 | "x2": 180, 14 | "y2": 55 15 | } 16 | ], 17 | "formatters": [], 18 | "text": [ 19 | { 20 | "x": 0, 21 | "y": 50, 22 | "font": "FreeSans18pt7b", 23 | "font_size": 2, 24 | "value": { 25 | "type": "variable", 26 | "variable": "timestamp", 27 | "formatter": { 28 | "type": "time", 29 | "args": { 30 | "timezone": "PT", 31 | "format": "%H:%M" 32 | } 33 | } 34 | } 35 | }, 36 | { 37 | "x": 185, 38 | "y": 18, 39 | "font": "FreeSansBold9pt7b", 40 | "value": { 41 | "formatter": { 42 | "type": "time", 43 | "args": { 44 | "timezone": "PT", 45 | "format": "%A" 46 | } 47 | }, 48 | "variable": "timestamp", 49 | "type": "variable" 50 | } 51 | }, 52 | { 53 | "x": 185, 54 | "y": 45, 55 | "font": "FreeSans9pt7b", 56 | "value": { 57 | "type": "variable", 58 | "variable": "timestamp", 59 | "formatter": { 60 | "type": "time", 61 | "args": { 62 | "timezone": "PT", 63 | "format": "%m/%d/%Y" 64 | } 65 | } 66 | }, 67 | "font_size": 1 68 | }, 69 | { 70 | "x": 5, 71 | "y": 72, 72 | "font": "FreeSansBold9pt7b", 73 | "value": { 74 | "type": "static", 75 | "value": "Sleep" 76 | } 77 | }, 78 | { 79 | "x": 65, 80 | "y": 72, 81 | "font": "FreeSans9pt7b", 82 | "value": { 83 | "type": "variable", 84 | "variable": "lights-out-time", 85 | "formatter": { 86 | "type": "time", 87 | "args": { 88 | "timezone": "PT", 89 | "format": "%H:%M" 90 | } 91 | } 92 | } 93 | }, 94 | { 95 | "x": 115, 96 | "y": 72, 97 | "font": "FreeSansBold9pt7b", 98 | "value": { 99 | "type": "static", 100 | "value": "|" 101 | } 102 | }, 103 | { 104 | "x": 120, 105 | "y": 72, 106 | "font": "FreeSans9pt7b", 107 | "value": { 108 | "type": "variable", 109 | "variable": "lights-out-time-relative", 110 | "formatter": { 111 | "args": {} 112 | } 113 | } 114 | } 115 | ], 116 | "bitmaps": [], 117 | "rectangles": [ 118 | { 119 | "style": "filled", 120 | "x": 7, 121 | "y": 80, 122 | "height": { 123 | "static": 40 124 | }, 125 | "width": { 126 | "max": 280, 127 | "variable": "sleep-time-percent", 128 | "variable_mode": "percent" 129 | }, 130 | "color": "black", 131 | "w": { 132 | "formatter": { 133 | "args": { 134 | "base": 0.35714285714 135 | }, 136 | "type": "ratio" 137 | }, 138 | "type": "variable", 139 | "variable": "sleep-time-percent" 140 | }, 141 | "h": { 142 | "type": "static", 143 | "value": "40" 144 | } 145 | }, 146 | { 147 | "style": "outline", 148 | "x": 6, 149 | "y": 79, 150 | "color": "black", 151 | "w": { 152 | "type": "static", 153 | "value": "282" 154 | }, 155 | "h": { 156 | "type": "static", 157 | "value": "42" 158 | } 159 | } 160 | ], 161 | "background_color": "white" 162 | } -------------------------------------------------------------------------------- /lib/Database/KeyValueDatabase.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #pragma once 6 | 7 | class KeyValueDatabase { 8 | public: 9 | static const uint8_t NEW_ROW_PADDING = 10; 10 | static const uint8_t MAX_COLUMN_SIZE = 255; 11 | static const uint8_t HEADER_SIZE = 16; 12 | static const uint16_t MAGIC_NUMBER = 0xFAFA; 13 | 14 | KeyValueDatabase(); 15 | 16 | /** 17 | * Opens the given file for reading 18 | * 19 | * @param File db 20 | */ 21 | void open(File db); 22 | 23 | /** 24 | * Close database 25 | */ 26 | void close(); 27 | 28 | /** 29 | * Initializes an open file with the appriopriate header and metadata 30 | */ 31 | void initialize(); 32 | 33 | /** 34 | * Find the value corresponding to the provided key and copy it into the provided buffer. 35 | * 36 | * @param key 37 | * @param keyLength 38 | * @param valueBuffer 39 | * @param valueBufferLen 40 | * @return bool true iff the row is found 41 | */ 42 | bool get(const char* key, size_t keyLength, char* valueBuffer, size_t valueBufferLen); 43 | 44 | /** 45 | * Upsert the value corresponding to the provided key 46 | * 47 | * @param key 48 | * @param keyLength 49 | * @param value 50 | * @param valueLength 51 | * @return bool true iff the row is found 52 | */ 53 | void set(const char* key, size_t keyLength, const char* value, size_t valueLength); 54 | 55 | /** 56 | * Remove the row corresponding to the provided key from the database 57 | * 58 | * @param key 59 | * @param keyLength 60 | * @return bool true iff if the row is found 61 | */ 62 | void erase(const char* key, size_t keyLength); 63 | 64 | /** 65 | * Return the number of keys in the database 66 | * 67 | * @return uint32_t 68 | */ 69 | uint32_t size(); 70 | 71 | /** 72 | * Reset scan pointer to the beginning of the database 73 | * 74 | */ 75 | void beginRead(); 76 | 77 | /** 78 | * Read a key and value pair. 79 | * 80 | * @return bool Return true iff there are more keys to read 81 | */ 82 | bool readEntry(char* key, size_t keyLength, char* value, size_t valueLength); 83 | 84 | /** 85 | * Skip over N entries 86 | * 87 | * @return bool if successful 88 | */ 89 | bool skipRead(size_t count); 90 | 91 | private: 92 | /** 93 | * Writes a row with the given key and value. Assumes that the file pointer is in the appropriate position. 94 | * 95 | * @param key 96 | * @param keyLength 97 | * @param value 98 | * @param valueLength 99 | * @param rowLength 100 | */ 101 | void writeRow(const char* key, size_t keyLength, const char* value, size_t valueLength, size_t rowLength); 102 | 103 | /** 104 | * Seeks to the row with the provided key and returns the size of the row. If no such row is found, 0 is 105 | * returned and the pointer will be at EOF. 106 | * 107 | * Pointer will be at the beginning of the value cell rather than the beginning of the row. 108 | * 109 | * @param key 110 | * @param keyLength 111 | * @return size_t length of the row, or 0 if not found 112 | */ 113 | size_t seekToRow(const char* key, size_t keyLength); 114 | 115 | /** 116 | * Finds the first empty row with size >= rowLength. If no such row is found, append a new one to the end 117 | * of the database. 118 | * 119 | * @param rowLength 120 | * @return size_t length of the found or created row 121 | */ 122 | size_t seekToEmptyRow(size_t rowLength); 123 | 124 | uint32_t readUint32(); 125 | void writeUint32(uint32_t val); 126 | 127 | void readColumn(char* buffer, size_t bufferLength); 128 | 129 | void flushSize(); 130 | void readSize(); 131 | 132 | File db; 133 | uint32_t _size; 134 | }; -------------------------------------------------------------------------------- /test/remote/lib/api_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'net/http' 5 | require 'net/http/post/multipart' 6 | require 'net/ping' 7 | require 'uri' 8 | require 'stringio' 9 | require 'tempfile' 10 | 11 | class ApiClient 12 | def initialize(host) 13 | @host = host 14 | end 15 | 16 | def self.from_environment 17 | ApiClient.new(ENV.fetch('EPAPER_TEMPLATES_HOSTNAME')) 18 | end 19 | 20 | def set_auth!(username, password) 21 | @username = username 22 | @password = password 23 | end 24 | 25 | def clear_auth! 26 | @username = nil 27 | @password = nil 28 | end 29 | 30 | def reboot 31 | post('/system', '{"command":"restart"}') 32 | end 33 | 34 | def request(type, path, req_body = nil, request: nil, allow_error: false) 35 | if request.nil? 36 | path = File.join('/api/v1', path) 37 | uri = URI("http://#{@host}#{path}") 38 | else 39 | uri = request.uri 40 | end 41 | 42 | Net::HTTP.start(uri.host, uri.port) do |http| 43 | if request.nil? 44 | req_type = Net::HTTP.const_get(type) 45 | req = req_type.new(uri) 46 | else 47 | req = request 48 | end 49 | 50 | if req_body 51 | req['Content-Type'] = 'application/json' 52 | req_body = req_body.to_json unless req_body.is_a?(String) 53 | req.body = req_body 54 | end 55 | 56 | req.basic_auth(@username, @password) if @username && @password 57 | 58 | res = http.request(req) 59 | res.value unless allow_error 60 | 61 | body = res.body 62 | 63 | if res['content-type'].downcase == 'application/json' 64 | body = JSON.parse(body) 65 | end 66 | 67 | body 68 | end 69 | end 70 | 71 | def upload_file(path, file) 72 | `curl -s "http://#{@host}#{path}" -X POST -F 'f=@#{file}'` 73 | end 74 | 75 | def upload_template(name, contents:) 76 | Tempfile.create do |filename| 77 | File.open(filename, 'w+') do |f| 78 | f.write(contents) 79 | end 80 | 81 | uri = URI("http://#{@host}/api/v1/templates") 82 | 83 | r = Net::HTTP::Post::Multipart.new( 84 | uri, 85 | 'template' => UploadIO.new(filename, 'application/json', name) 86 | ) 87 | 88 | request(nil, nil, nil, request: r) 89 | end 90 | end 91 | 92 | def upload_bitmap(name, contents:, metadata:) 93 | Tempfile.create do |filename| 94 | File.open(filename, 'w+') do |f| 95 | f.write(contents) 96 | end 97 | 98 | uri = URI("http://#{@host}/api/v1/bitmaps") 99 | 100 | r = Net::HTTP::Post::Multipart.new( 101 | uri, 102 | 'bitmap' => UploadIO.new(filename, 'application/octet-stream', name), 103 | 'meatadata' => UploadIO.new(StringIO.new(metadata.to_json), 'application/json', 'metadata.json') 104 | ) 105 | 106 | request(nil, nil, nil, request: r) 107 | end 108 | end 109 | 110 | def patch_settings(settings) 111 | put('/settings', settings) 112 | end 113 | 114 | def get(path, **args) 115 | request(:Get, path, **args) 116 | end 117 | 118 | def put(path, body, **args) 119 | request(:Put, path, body, **args) 120 | end 121 | 122 | def post(path, body, **args) 123 | request(:Post, path, body, **args) 124 | end 125 | 126 | def delete(path, **args) 127 | request(:Delete, path, **args) 128 | end 129 | 130 | def available? 131 | Net::Ping::External.new(@host).ping? 132 | end 133 | 134 | def wait_for_available! 135 | 10.times do 136 | return true if available? 137 | 138 | sleep 1 139 | end 140 | 141 | false 142 | end 143 | 144 | def update_variables(vars = {}) 145 | put('/variables', vars) 146 | end 147 | 148 | def get_variable(name) 149 | response = get("/variables/#{name}") 150 | response['found'] && response['variable']['value'] 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /scripts/vardb/variabledb.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bindata' 4 | require 'tempfile' 5 | require 'hexdump' 6 | 7 | MAX_KEY_SIZE = 255 8 | VALUE_PADDING_FACTOR = 1.2 9 | action, key, value = ARGV 10 | 11 | class Database 12 | def initialize(file) 13 | @f = File.open(file, "rb+") 14 | end 15 | 16 | def self.open(file, &block) 17 | db = Database.new(file) 18 | begin 19 | yield(db) 20 | ensure 21 | db.close 22 | end 23 | end 24 | 25 | def self.open_tmp(&block) 26 | begin 27 | file = Tempfile.new('vardb') 28 | Database.open(file.path, &block) 29 | ensure 30 | File.delete(file) unless file.nil? 31 | end 32 | end 33 | 34 | def close 35 | @f.close 36 | end 37 | 38 | def get(key) 39 | row_size = find_row_by_key(key) 40 | 41 | if row_size 42 | @f.read(row_size - key.length).split("\x0").first 43 | end 44 | end 45 | 46 | def set(key, value) 47 | existing_row_size = find_row_by_key(key) 48 | new_row_size = key.length + value.length 49 | 50 | if existing_row_size.nil? 51 | capacity = find_empty_row(new_row_size) 52 | write_row(key, value, capacity) 53 | elsif existing_row_size < new_row_size 54 | # Clear this row by seeking to beginning and clearing the key 55 | @f.seek(-key.length-1, IO::SEEK_CUR) 56 | write_terminator() 57 | 58 | capacity = find_empty_row(new_row_size) 59 | write_row(key, value, capacity) 60 | else 61 | @f.write(value) 62 | write_terminator() unless existing_row_size == new_row_size 63 | end 64 | 65 | @f.flush 66 | end 67 | 68 | def write_row(key, value, max_row_len) 69 | key_len = BinData::Uint8.new(key.length) 70 | value_len = BinData::Uint8.new(max_row_len - key_len) 71 | 72 | key_len.write(@f) 73 | @f.write(key) 74 | 75 | value_len.write(@f) 76 | @f.write(value) 77 | write_terminator() if value_len > value.length 78 | end 79 | 80 | def find_row_by_key(key) 81 | @f.seek(0, IO::SEEK_SET) 82 | 83 | while !@f.eof? 84 | read_key_len = read_length() 85 | read_key = @f.read(read_key_len) 86 | read_value_length = read_length() 87 | 88 | if key == read_key 89 | return read_key_len + read_value_length 90 | end 91 | 92 | @f.seek(read_value_length, IO::SEEK_CUR) 93 | end 94 | 95 | return nil 96 | end 97 | 98 | def find_empty_row(min_size) 99 | @f.seek(0, IO::SEEK_SET) 100 | 101 | while !@f.eof? 102 | read_key_len = read_length() 103 | 104 | # Read first byte of key and skip over the rest 105 | key_start = read_byte() 106 | @f.seek(read_key_len-1, IO::SEEK_CUR) if read_key_len > 1 107 | 108 | read_value_len = read_length() 109 | capacity = read_key_len + read_value_len 110 | 111 | if key_start == 0 && capacity >= min_size 112 | @f.seek(-read_key_len-2, IO::SEEK_CUR) 113 | return capacity 114 | end 115 | 116 | # Skip over value 117 | @f.seek(read_value_len, IO::SEEK_CUR) 118 | end 119 | 120 | # We're at eof, meaning there wasn't an empty row. We append a new one, 121 | # adding the padding size. 122 | row_capacity = (min_size * VALUE_PADDING_FACTOR).round 123 | 124 | # Will need to fill the row, as any padding bytes won't be filled otherwise 125 | write_blank(row_capacity+2) 126 | @f.seek(-row_capacity-2, IO::SEEK_CUR) 127 | 128 | return row_capacity 129 | end 130 | 131 | def dump 132 | pos = @f.pos 133 | @f.seek(0, IO::SEEK_SET) 134 | Hexdump.dump(@f) 135 | @f.seek(pos, IO::SEEK_SET) 136 | end 137 | 138 | def read_length 139 | read_byte() 140 | end 141 | 142 | def read_byte 143 | @f.read(1).bytes.first 144 | end 145 | 146 | def write_blank(n) 147 | n.times { write_terminator() } 148 | end 149 | 150 | def write_terminator 151 | BinData::Uint8.new(0).write(@f) 152 | end 153 | end 154 | 155 | # db = Database.new 156 | 157 | # if action == "set" 158 | # db.set(key, value) 159 | # elsif action == "get" 160 | # puts db.get(key) 161 | # end 162 | 163 | # db.close -------------------------------------------------------------------------------- /web/src/settings/SettingsForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react"; 2 | import Form from "react-jsonschema-form"; 3 | import Tab from "react-bootstrap/Tab"; 4 | import Row from "react-bootstrap/Row"; 5 | import Col from "react-bootstrap/Col"; 6 | import Nav from "react-bootstrap/Nav"; 7 | import Button from "react-bootstrap/Button"; 8 | 9 | import schemaBuilder from "./schema"; 10 | import ui_schema from "./ui_schema"; 11 | import api from "../util/api"; 12 | import SiteLoader from "../util/SiteLoader"; 13 | import { CheckboxWidget } from "./CheckboxWidget"; 14 | import { SelectWidget } from "./SelectWidget"; 15 | import useGlobalState from "../state/global_state"; 16 | 17 | const CustomRjsfWidgets = { 18 | CheckboxWidget: CheckboxWidget, 19 | SelectWidget: SelectWidget 20 | }; 21 | 22 | export default props => { 23 | const [globalState, globalActions] = useGlobalState(); 24 | const [formState, setFormState] = useState(null); 25 | const [isSaving, setSaving] = useState(false); 26 | const [errors, setErrors] = useState(null); 27 | const [schema, setSchema] = useState([]); 28 | 29 | useEffect(() => { 30 | if (formState == null) { 31 | api.get("/settings").then(x => { 32 | const data = x.data; 33 | 34 | Object.entries(ui_schema).map(([field, x]) => { 35 | if (x.transformer) { 36 | data[field] = x.transformer(data[field]); 37 | } 38 | }); 39 | 40 | setFormState(data); 41 | }); 42 | } 43 | }, [ui_schema]); 44 | 45 | useEffect(() => { 46 | if (globalState.screenMetadata && globalState.screenMetadata.screens) { 47 | let a = schemaBuilder; 48 | setSchema(schemaBuilder({displayTypes: globalState.screenMetadata})); 49 | } 50 | }, [globalState]) 51 | 52 | const onChange = useCallback( 53 | form => setFormState({ ...formState, ...form.formData }), 54 | [] 55 | ); 56 | const onSubmit = useCallback(form => { 57 | setSaving(true); 58 | 59 | // All values must be strings for Bleeper 60 | const settingsFields = Object.entries(form.formData).map(([k, v]) => [ 61 | k, 62 | v ? v.toString() : "" 63 | ]); 64 | 65 | api.put("/settings", Object.fromEntries(settingsFields)).then( 66 | x => setSaving(false), 67 | x => setErrors(x) 68 | ); 69 | }, []); 70 | 71 | return ( 72 | <> 73 | {formState == null && } 74 | {formState != null && ( 75 | <> 76 |

Settings

77 | 78 | 79 | 80 | 87 | 88 | 89 | 90 | {schema.map(x => ( 91 | 92 |
102 | 109 |
110 |
111 | ))} 112 |
113 | 114 |
115 |
116 | 117 | )} 118 | 119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /lib/Variables/VariableFormatterFactory.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | VariableFormatterFactory::VariableFormatterFactory( 4 | const JsonVariant& referenceFormatters) 5 | : defaultFormatter(new IdentityVariableFormatter()) { 6 | if (referenceFormatters.is()) { 7 | for (JsonPair kv : referenceFormatters.as()) { 8 | String key = kv.key().c_str(); 9 | JsonObject formatterSpec = kv.value().as(); 10 | 11 | refFormatters[key] = _createInternal(formatterSpec, false); 12 | } 13 | } else if (referenceFormatters.is()) { 14 | for (JsonObject formatter : referenceFormatters.as()) { 15 | String key = formatter["name"]; 16 | Serial.printf_P(PSTR("formatter key = %s\n"), key.c_str()); 17 | 18 | refFormatters[key] = _createInternal(formatter["formatter"], false); 19 | } 20 | } else { 21 | Serial.println( 22 | F("WARNING: formatter definition block either missing or is of invalid " 23 | "type.")); 24 | } 25 | } 26 | 27 | std::shared_ptr VariableFormatterFactory::create( 28 | JsonObject spec) { 29 | return _createInternal(spec, true); 30 | } 31 | 32 | std::shared_ptr VariableFormatterFactory::getReference( 33 | String refKey, bool allowReference) { 34 | if (!allowReference) { 35 | Serial.println( 36 | F("WARNING: Tried to reference a formatter when references were " 37 | "disallowed (probably because it's a reference to begin with!)")); 38 | return defaultFormatter; 39 | } 40 | 41 | if (refFormatters.count(refKey) > 0) { 42 | return refFormatters[refKey]; 43 | } else { 44 | Serial.printf_P(PSTR("WARNING: undefined reference to formatter `%s'\n"), 45 | refKey.c_str()); 46 | return defaultFormatter; 47 | } 48 | } 49 | 50 | std::shared_ptr 51 | VariableFormatterFactory::_createInternal( 52 | JsonObject spec, bool allowReference) { 53 | JsonVariant formatterSpec = spec; 54 | 55 | if (formatterSpec.containsKey("formatter")) { 56 | formatterSpec = spec["formatter"]; 57 | } 58 | 59 | String formatterDef; 60 | JsonObject formatterArgs; 61 | 62 | // old v1 spec where formatter is defined inline with the spec 63 | // and defines references with an anchor prefix 64 | if (formatterSpec.is()) { 65 | formatterDef = formatterSpec.as(); 66 | formatterArgs = spec["args"]; 67 | 68 | // Handle references first (code is clearer this way) 69 | if (formatterDef.startsWith("&")) { 70 | String refName = formatterDef.substring(1); 71 | return getReference(refName, allowReference); 72 | } 73 | } else if (formatterSpec.is()) { 74 | JsonObject formatterSpecObj = formatterSpec; 75 | formatterDef = formatterSpecObj["type"].as(); 76 | 77 | if (formatterDef.equalsIgnoreCase("ref")) { 78 | String refName = formatterSpecObj["ref"]; 79 | return getReference(refName, allowReference); 80 | } 81 | 82 | formatterArgs = formatterSpecObj["args"]; 83 | } 84 | 85 | if (formatterDef.equalsIgnoreCase("time")) { 86 | return TimeVariableFormatter::build(formatterArgs); 87 | } else if (formatterDef.equalsIgnoreCase("cases")) { 88 | return std::make_shared(formatterArgs); 89 | } else if (formatterDef.equalsIgnoreCase("round")) { 90 | uint8_t numDigits = 0; 91 | if (formatterArgs.containsKey("digits")) { 92 | numDigits = formatterArgs["digits"]; 93 | } 94 | 95 | return std::make_shared(numDigits); 96 | } else if (formatterDef.equalsIgnoreCase("ratio")) { 97 | float base = 0.0; 98 | if (formatterArgs.containsKey("base")) { 99 | base = formatterArgs["base"]; 100 | } 101 | 102 | return std::make_shared(base); 103 | } else if (formatterDef.equalsIgnoreCase("pfstring")) { 104 | return PrintfFormatterString::build(formatterArgs); 105 | } else if (formatterDef.equalsIgnoreCase("pfnumeric")) { 106 | return PrintfFormatterNumeric::build(formatterArgs); 107 | } else { 108 | return defaultFormatter; 109 | } 110 | } -------------------------------------------------------------------------------- /lib/HTTP/EpaperWebServer.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #if defined(ESP32) 9 | #include 10 | #endif 11 | 12 | #ifndef _EPAPER_WEB_SERVER_H 13 | #define _EPAPER_WEB_SERVER_H 14 | 15 | using RichHttpConfig = RichHttp::Generics::Configs::AsyncWebServer; 16 | using RequestContext = RichHttpConfig::RequestContextType; 17 | 18 | class EpaperWebServer { 19 | public: 20 | using OnChangeFn = std::function; 21 | using OnCancelSleepFn = std::function; 22 | 23 | EpaperWebServer(DisplayTemplateDriver*& driver, Settings& settings); 24 | ~EpaperWebServer(); 25 | 26 | void onSettingsChange(OnChangeFn changeFn); 27 | void onCancelSleep(OnCancelSleepFn cancelSleepFn); 28 | void setDeepSleepActive(bool deepSleepActive); 29 | void begin(); 30 | uint16_t getPort() const; 31 | void handleClient(); 32 | 33 | private: 34 | DisplayTemplateDriver*& driver; 35 | Settings& settings; 36 | PassthroughAuthProvider authProvider; 37 | RichHttpServer server; 38 | uint16_t port; 39 | OnChangeFn changeFn; 40 | OnCancelSleepFn cancelSleepFn; 41 | AsyncWebSocket wsServer; 42 | bool deepSleepActive; 43 | bool updateSuccessful; 44 | 45 | // firmware update handlers 46 | void handleFirmwareUpdateUpload(RequestContext& request); 47 | void handleFirmwareUpdateComplete(RequestContext& request); 48 | 49 | // Variable update observer 50 | void handleVariableUpdate(const String& name, const String& value); 51 | // Region update observer 52 | void handleRegionUpdate(const String& regionId, const String& variableKey, const String& variableValue); 53 | 54 | // Variables CRUD 55 | void handleListVariables(RequestContext& request); 56 | void handleUpdateVariables(RequestContext& request); 57 | void handleDeleteVariable(RequestContext& request); 58 | void handleClearVariables(RequestContext& request); 59 | void handleGetVariable(RequestContext& request); 60 | void handleGetFormattedVariables(RequestContext& request); 61 | 62 | void handleNoOp(RequestContext& request); 63 | 64 | // General info routes 65 | void handleGetSystem(RequestContext& request); 66 | void handlePostSystem(RequestContext& request); 67 | 68 | // General helpers 69 | void handleListDirectory(const char* dir, RequestContext& request); 70 | void handleCreateFile(const char* filePrefix, RequestContext& request); 71 | void handleDeleteFile(const String& file, RequestContext& request); 72 | void listDirectory(const char* dir, JsonArray result); 73 | 74 | // CRUD handlers for Bitmaps 75 | void handleDeleteBitmap(RequestContext& request); 76 | void handleShowBitmap(RequestContext& request); 77 | void handleCreateBitmap(RequestContext& request); 78 | void handleCreateBitmapFinish(RequestContext& request); 79 | void handleListBitmaps(RequestContext& request); 80 | 81 | // CRUD handlers for Templates 82 | void handleDeleteTemplate(RequestContext& request); 83 | void handleShowTemplate(RequestContext& request); 84 | void handleUpdateTemplate(RequestContext& request); 85 | 86 | void handleUpdateSettings(RequestContext& request); 87 | void handleGetSettings(RequestContext& request); 88 | 89 | void handleGetScreens(RequestContext& request); 90 | void handleResolveVariables(RequestContext& request); 91 | 92 | // Misc helpers 93 | void handleUpdateFile(ArUploadHandlerFunction* request, const char* filename); 94 | void handleServeFile( 95 | const char* filename, 96 | const char* contentType, 97 | const char* defaultText, 98 | RequestContext& request 99 | ); 100 | void handleServeGzip_P( 101 | const char* contentType, 102 | const uint8_t* text, 103 | size_t length, 104 | RequestContext& request 105 | ); 106 | void _handleServeGzip_P( 107 | const char* contentType, 108 | const uint8_t* text, 109 | size_t length, 110 | AsyncWebServerRequest* request 111 | ); 112 | bool serveFile(const char* file, const char* contentType, RequestContext& request); 113 | void handleUpdateJsonFile(const String& file, RequestContext& request); 114 | }; 115 | 116 | #endif 117 | -------------------------------------------------------------------------------- /web/src/util/mungers.js: -------------------------------------------------------------------------------- 1 | const deepmerge = require('deepmerge'); 2 | 3 | export function drillMerge(object, path, newValue) { 4 | return drillModify(object, path, obj => ({ ...obj, ...newValue })); 5 | } 6 | 7 | export function drillModify(object, path, fn) { 8 | if (path.length === 0) { 9 | return fn(object); 10 | } 11 | 12 | const head = path[0]; 13 | const rest = path.slice(1); 14 | 15 | if (Array.isArray(object)) { 16 | const updated = [...object]; 17 | updated[head] = drillModify(object[head], rest, fn); 18 | return updated; 19 | } else { 20 | return { 21 | ...object, 22 | [head]: drillModify(object[head] || {}, rest, fn) 23 | }; 24 | } 25 | } 26 | 27 | export function drillUpdate(object, path, fn) { 28 | if (path.length === 0) { 29 | return fn(object); 30 | } 31 | 32 | const head = path[0]; 33 | const rest = path.slice(1); 34 | 35 | return drillUpdate(object[head], rest, fn); 36 | } 37 | 38 | export function drillExtract(object, path) { 39 | return path.reduce((a, x) => a[x] || {}, object); 40 | } 41 | 42 | function _drillFilter(object, path) { 43 | if (path.length === 0) { 44 | return object; 45 | } else { 46 | const [key, ...rest] = path; 47 | const result = _drillFilter(object[key], rest); 48 | 49 | if (Array.isArray(object)) { 50 | return [result] 51 | } else { 52 | return {[key]: result}; 53 | } 54 | } 55 | } 56 | 57 | export function drillFilter(object, paths) { 58 | const filtered = paths.map(x => _drillFilter(object, x)) 59 | return deepmerge.all(filtered); 60 | } 61 | 62 | export function deepClearFields(object, options = {}) { 63 | const { value = "" } = options; 64 | 65 | if (object instanceof Object) { 66 | return Object.fromEntries( 67 | Object.entries(object).map(([k, v]) => [k, deepClearFields(v, options)]) 68 | ); 69 | } else { 70 | return value; 71 | } 72 | } 73 | 74 | function _deepClearNonMatching(o1, o2) { 75 | if (!o1 || !o2 || typeof o1 !== "object" || typeof o2 !== "object") { 76 | return null; 77 | } 78 | 79 | const keyUnion = [...new Set([...Object.keys(o1), ...Object.keys(o2)])]; 80 | 81 | const commonFields = keyUnion 82 | .map(k => { 83 | if (o1[k] === o2[k]) { 84 | return [k, o1[k]]; 85 | } else if (typeof o1[k] === "object" && typeof o2[k] === "object") { 86 | return [k, _deepClearNonMatching(o1[k], o2[k])]; 87 | } else { 88 | return null; 89 | } 90 | }) 91 | .filter(x => x != null && x[1] != null); 92 | 93 | if (commonFields.length > 0) { 94 | return Object.fromEntries(commonFields); 95 | } else { 96 | return null; 97 | } 98 | } 99 | 100 | export function deepClearNonMatching(os) { 101 | if (os.length == 0) { 102 | return null; 103 | } else if (os.length == 1) { 104 | return os[0]; 105 | } else { 106 | const [a, ...rest] = os; 107 | return _deepClearNonMatching(a, deepClearNonMatching(rest)); 108 | } 109 | } 110 | 111 | export function deepPatch(target, patch) { 112 | if (!target) { 113 | return; 114 | } 115 | 116 | Object.entries(patch).forEach(([k, v]) => { 117 | if ( 118 | v && 119 | target[k] && 120 | !Array.isArray(v) && 121 | typeof v === "object" && 122 | typeof target[k] === "object" 123 | ) { 124 | deepPatch(target[k], v); 125 | } else { 126 | target[k] = v; 127 | } 128 | }); 129 | 130 | return target; 131 | } 132 | 133 | export const arrayGroupReducer = (a, x) => { 134 | if (a) { 135 | a.push(x); 136 | return a; 137 | } else { 138 | return [x]; 139 | } 140 | }; 141 | 142 | export const lastValueGroupReducer = (a, x) => { 143 | return x; 144 | }; 145 | 146 | export const setGroupReducer = (a, x) => { 147 | if (a) { 148 | a.add(x); 149 | return a; 150 | } else { 151 | return new Set([x]); 152 | } 153 | }; 154 | 155 | export function groupBy( 156 | o, 157 | fn, 158 | { valueFn = x => x, groupReducer = arrayGroupReducer } = {} 159 | ) { 160 | return o.reduce((a, x) => { 161 | const key = fn(x); 162 | const value = valueFn(x); 163 | a[key] = groupReducer(a[key], value); 164 | 165 | return a; 166 | }, {}); 167 | } 168 | -------------------------------------------------------------------------------- /examples/alarm_clock/nodered_flow.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "7ff4fc88.583d84", 4 | "type": "tab", 5 | "label": "Alarm Clock Display", 6 | "disabled": false, 7 | "info": "" 8 | }, 9 | { 10 | "id": "757846ba.6750c8", 11 | "type": "mqtt in", 12 | "z": "7ff4fc88.583d84", 13 | "name": "", 14 | "topic": "dash_buttons/nightstand", 15 | "qos": "2", 16 | "datatype": "auto", 17 | "broker": "b558af9.9ad0b5", 18 | "x": 142, 19 | "y": 92, 20 | "wires": [["29889c2f.a58454", "14b91a9a.a69d55"]] 21 | }, 22 | { 23 | "id": "29889c2f.a58454", 24 | "type": "function", 25 | "z": "7ff4fc88.583d84", 26 | "name": "Format MQTT message", 27 | "func": "const time = new Date();\n\nflow.set('last_time', time);\n\nreturn {\n payload: Math.round(time.getTime() / 1000),\n topic: \"template-displays/alarm-clock/lights-out-time\",\n retain: true\n};", 28 | "outputs": 1, 29 | "noerr": 0, 30 | "x": 422, 31 | "y": 95, 32 | "wires": [["115a9fe5.c6d5b", "baf5586.da1b9a8"]] 33 | }, 34 | { 35 | "id": "115a9fe5.c6d5b", 36 | "type": "mqtt out", 37 | "z": "7ff4fc88.583d84", 38 | "name": "", 39 | "topic": "", 40 | "qos": "", 41 | "retain": "true", 42 | "broker": "b558af9.9ad0b5", 43 | "x": 894, 44 | "y": 240, 45 | "wires": [] 46 | }, 47 | { 48 | "id": "baf5586.da1b9a8", 49 | "type": "debug", 50 | "z": "7ff4fc88.583d84", 51 | "name": "", 52 | "active": true, 53 | "tosidebar": true, 54 | "console": false, 55 | "tostatus": false, 56 | "complete": "true", 57 | "targetType": "full", 58 | "x": 835, 59 | "y": 56, 60 | "wires": [] 61 | }, 62 | { 63 | "id": "e552d2ab.2f8d7", 64 | "type": "inject", 65 | "z": "7ff4fc88.583d84", 66 | "name": "", 67 | "topic": "", 68 | "payload": "", 69 | "payloadType": "date", 70 | "repeat": "", 71 | "crontab": "", 72 | "once": false, 73 | "onceDelay": 0.1, 74 | "x": 122, 75 | "y": 182, 76 | "wires": [["29889c2f.a58454"]] 77 | }, 78 | { 79 | "id": "14b91a9a.a69d55", 80 | "type": "function", 81 | "z": "7ff4fc88.583d84", 82 | "name": "Relative Time & %age MQTT messages", 83 | "func": "const lastTime = flow.get('last_time');\nconst diff = (new Date()) - lastTime;\nconst MIN_INTERVAL = 15; // minutes\n\n// Convert to nearest INTERVAL minutes\nconst diffMins = diff / 1000 / 60;\nconst dHours = Math.floor(diffMins / 60);\nconst dMins = diffMins % 60;\nconst dMins15MinRound = Math.floor(dMins / MIN_INTERVAL) * MIN_INTERVAL;\n\nconst sleepProgress = Math.min(100, 100*(diffMins / (7*60.0)))\n\nreturn [[\n{\n payload: `${dHours}h ${dMins15MinRound}m`,\n topic: \"template-displays/alarm-clock/lights-out-time-relative\",\n retain: true\n},\n{\n payload: sleepProgress*1.0,\n topic: \"template-displays/alarm-clock/sleep-time-percent\",\n retain: true\n}\n]]", 84 | "outputs": 1, 85 | "noerr": 0, 86 | "x": 435, 87 | "y": 215, 88 | "wires": [["baf5586.da1b9a8", "115a9fe5.c6d5b"]] 89 | }, 90 | { 91 | "id": "edad0a89.1902b8", 92 | "type": "inject", 93 | "z": "7ff4fc88.583d84", 94 | "name": "", 95 | "topic": "", 96 | "payload": "", 97 | "payloadType": "date", 98 | "repeat": "300", 99 | "crontab": "", 100 | "once": false, 101 | "onceDelay": 0.1, 102 | "x": 128, 103 | "y": 259, 104 | "wires": [["14b91a9a.a69d55"]] 105 | }, 106 | { 107 | "id": "6eaa4a54.4190d4", 108 | "type": "mqtt in", 109 | "z": "7ff4fc88.583d84", 110 | "name": "", 111 | "topic": "template-displays/bart/+", 112 | "qos": "2", 113 | "datatype": "auto", 114 | "broker": "b558af9.9ad0b5", 115 | "x": 138, 116 | "y": 362, 117 | "wires": [["ec6fa3dd.094a"]] 118 | }, 119 | { 120 | "id": "ec6fa3dd.094a", 121 | "type": "function", 122 | "z": "7ff4fc88.583d84", 123 | "name": "Filter relevant values", 124 | "func": "const FORWARDED_VARS = [\n 'forecast_high',\n 'forecast_low',\n 'forecast_temp',\n 'weather_icon',\n 'outside_temp',\n 'sf_forecast_temp'\n];\n\nconst topicParts = msg.topic.split('/');\nconst variable = topicParts[topicParts.length - 1];\n\nif (FORWARDED_VARS.includes(variable)) {\n msg.topic = `template-displays/alarm-clock/${variable}`;\n return msg;\n}", 125 | "outputs": 1, 126 | "noerr": 0, 127 | "x": 441, 128 | "y": 358, 129 | "wires": [["baf5586.da1b9a8", "115a9fe5.c6d5b"]] 130 | }, 131 | { 132 | "id": "b558af9.9ad0b5", 133 | "type": "mqtt-broker", 134 | "z": "", 135 | "name": "", 136 | "broker": "my=-broker", 137 | "port": "1883", 138 | "clientid": "", 139 | "usetls": false, 140 | "compatmode": true, 141 | "keepalive": "60", 142 | "cleansession": true, 143 | "birthTopic": "", 144 | "birthQos": "0", 145 | "birthPayload": "", 146 | "willTopic": "", 147 | "willQos": "0", 148 | "willPayload": "" 149 | } 150 | ] 151 | -------------------------------------------------------------------------------- /scripts/vardb/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | =begin 50 | # This allows you to limit a spec run to individual examples or groups 51 | # you care about by tagging them with `:focus` metadata. When nothing 52 | # is tagged with `:focus`, all examples get run. RSpec also provides 53 | # aliases for `it`, `describe`, and `context` that include `:focus` 54 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 55 | config.filter_run_when_matching :focus 56 | 57 | # Allows RSpec to persist some state between runs in order to support 58 | # the `--only-failures` and `--next-failure` CLI options. We recommend 59 | # you configure your source control system to ignore this file. 60 | config.example_status_persistence_file_path = "spec/examples.txt" 61 | 62 | # Limits the available syntax to the non-monkey patched syntax that is 63 | # recommended. For more details, see: 64 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 65 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 66 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 67 | config.disable_monkey_patching! 68 | 69 | # This setting enables warnings. It's recommended, but in some cases may 70 | # be too noisy due to issues in dependencies. 71 | config.warnings = true 72 | 73 | # Many RSpec users commonly either run the entire suite or an individual 74 | # file, and it's useful to allow more verbose output when running an 75 | # individual spec file. 76 | if config.files_to_run.one? 77 | # Use the documentation formatter for detailed output, 78 | # unless a formatter has already been configured 79 | # (e.g. via a command-line flag). 80 | config.default_formatter = "doc" 81 | end 82 | 83 | # Print the 10 slowest examples and example groups at the 84 | # end of the spec run, to help surface which specs are running 85 | # particularly slow. 86 | config.profile_examples = 10 87 | 88 | # Run specs in random order to surface order dependencies. If you find an 89 | # order dependency and want to debug it, you can fix the order by providing 90 | # the seed, which is printed after each run. 91 | # --seed 1234 92 | config.order = :random 93 | 94 | # Seed global randomization in this process using the `--seed` CLI option. 95 | # Setting this allows you to use `--seed` to deterministically reproduce 96 | # test failures related to randomization by passing the same `--seed` value 97 | # as the one that triggered the failure. 98 | Kernel.srand config.seed 99 | =end 100 | end 101 | -------------------------------------------------------------------------------- /web/src/templates/TemplatesIndex.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | faPlus, 3 | faTv, 4 | faBackward, 5 | faLongArrowAltLeft 6 | } from "@fortawesome/free-solid-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import React, { useCallback, useEffect, useRef, useState } from "react"; 9 | import Col from "react-bootstrap/Col"; 10 | import Nav from "react-bootstrap/Nav"; 11 | import Row from "react-bootstrap/Row"; 12 | import { Link, NavLink, useRouteMatch } from "react-router-dom"; 13 | import api from "../util/api"; 14 | import SiteLoader from "../util/SiteLoader"; 15 | import TemplateEditor from "./TemplateEditor"; 16 | import useGlobalState from "../state/global_state"; 17 | import MemoizedFontAwesomeIcon from "../util/MemoizedFontAwesomeIcon"; 18 | 19 | const TemplatesList = ({ 20 | templates, 21 | activeTemplate, 22 | onSelect, 23 | selectedValue 24 | }) => { 25 | return ( 26 | 59 | ); 60 | }; 61 | 62 | export default props => { 63 | const { params: { template_name: templateName } = {} } = 64 | useRouteMatch("/templates/:template_name") || {}; 65 | const isNew = templateName === "new"; 66 | const isIndex = !isNew && !templateName; 67 | 68 | const [globalState, globalActions] = useGlobalState(); 69 | const [templates, setTemplates] = useState(null); 70 | const [templateContents, setTemplateContents] = useState({}); 71 | const selectedTemplateContents = 72 | templateName && templateContents[templateName]; 73 | const [activeTemplate, setActiveTemplate] = useState(null); 74 | 75 | const triggerReload = useCallback(() => { 76 | api.get("/templates").then(x => setTemplates(x.data.templates)); 77 | globalActions.loadSettings({forceReload: true}).then(settings => { 78 | setActiveTemplate(settings["display.template_name"]); 79 | }); 80 | }, [setActiveTemplate, templates, setTemplates]); 81 | 82 | useEffect(() => { 83 | globalActions.loadBitmaps(); 84 | }, []); 85 | 86 | useEffect(() => { 87 | if (templates == null) { 88 | triggerReload(); 89 | } 90 | }, [triggerReload, templates]); 91 | 92 | useEffect(() => { 93 | if (templateName && templateName !== "new") { 94 | api 95 | .get(`/templates/${templateName}`) 96 | .then(x => 97 | setTemplateContents({ ...templateContents, [templateName]: x.data }) 98 | ); 99 | } 100 | }, [templateName]); 101 | 102 | const isSelectedTemplateActive = 103 | templateName && 104 | activeTemplate && 105 | templateName === activeTemplate.split("/")[2]; 106 | 107 | return ( 108 | <> 109 | {templates == null && } 110 | {templates != null && ( 111 | <> 112 |

113 | {!isIndex && ( 114 | <> 115 | 120 | 124 | 125 | 126 | )} 127 | {isIndex && "Templates"} 128 | {isNew && "New Template"} 129 | {!isIndex && !isNew && `Template: ${templateName}`} 130 |

131 | 132 | {!templateName && ( 133 | 134 | 138 | 139 | )} 140 | 141 | {templateName && ( 142 | 148 | )} 149 | 150 | 151 | 152 | )} 153 | 154 | ); 155 | }; 156 | -------------------------------------------------------------------------------- /test/remote/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "dotenv" 2 | 3 | require "api_client" 4 | 5 | Dotenv.load("epaper_templates.env") 6 | 7 | # This file was generated by the `rspec --init` command. Conventionally, all 8 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 9 | # The generated `.rspec` file contains `--require spec_helper` which will cause 10 | # this file to always be loaded, without a need to explicitly require it in any 11 | # files. 12 | # 13 | # Given that it is always loaded, you are encouraged to keep this file as 14 | # light-weight as possible. Requiring heavyweight dependencies from this file 15 | # will add to the boot time of your test suite on EVERY test run, even for an 16 | # individual file that may not need all of that loaded. Instead, consider making 17 | # a separate helper file that requires the additional dependencies and performs 18 | # the additional setup, and require it from the spec files that actually need 19 | # it. 20 | # 21 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 22 | RSpec.configure do |config| 23 | # rspec-expectations config goes here. You can use an alternate 24 | # assertion/expectation library such as wrong or the stdlib/minitest 25 | # assertions if you prefer. 26 | config.expect_with :rspec do |expectations| 27 | # This option will default to `true` in RSpec 4. It makes the `description` 28 | # and `failure_message` of custom matchers include text for helper methods 29 | # defined using `chain`, e.g.: 30 | # be_bigger_than(2).and_smaller_than(4).description 31 | # # => "be bigger than 2 and smaller than 4" 32 | # ...rather than: 33 | # # => "be bigger than 2" 34 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 35 | end 36 | 37 | # rspec-mocks config goes here. You can use an alternate test double 38 | # library (such as bogus or mocha) by changing the `mock_with` option here. 39 | config.mock_with :rspec do |mocks| 40 | # Prevents you from mocking or stubbing a method that does not exist on 41 | # a real object. This is generally recommended, and will default to 42 | # `true` in RSpec 4. 43 | mocks.verify_partial_doubles = true 44 | end 45 | 46 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 47 | # have no way to turn it off -- the option exists only for backwards 48 | # compatibility in RSpec 3). It causes shared context metadata to be 49 | # inherited by the metadata hash of host groups and examples, rather than 50 | # triggering implicit auto-inclusion in groups with matching metadata. 51 | config.shared_context_metadata_behavior = :apply_to_host_groups 52 | 53 | # The settings below are suggested to provide a good initial experience 54 | # with RSpec, but feel free to customize to your heart's content. 55 | =begin 56 | # This allows you to limit a spec run to individual examples or groups 57 | # you care about by tagging them with `:focus` metadata. When nothing 58 | # is tagged with `:focus`, all examples get run. RSpec also provides 59 | # aliases for `it`, `describe`, and `context` that include `:focus` 60 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 61 | config.filter_run_when_matching :focus 62 | 63 | # Allows RSpec to persist some state between runs in order to support 64 | # the `--only-failures` and `--next-failure` CLI options. We recommend 65 | # you configure your source control system to ignore this file. 66 | config.example_status_persistence_file_path = "spec/examples.txt" 67 | 68 | # Limits the available syntax to the non-monkey patched syntax that is 69 | # recommended. For more details, see: 70 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 71 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 72 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 73 | config.disable_monkey_patching! 74 | 75 | # This setting enables warnings. It's recommended, but in some cases may 76 | # be too noisy due to issues in dependencies. 77 | config.warnings = true 78 | 79 | # Many RSpec users commonly either run the entire suite or an individual 80 | # file, and it's useful to allow more verbose output when running an 81 | # individual spec file. 82 | if config.files_to_run.one? 83 | # Use the documentation formatter for detailed output, 84 | # unless a formatter has already been configured 85 | # (e.g. via a command-line flag). 86 | config.default_formatter = "doc" 87 | end 88 | 89 | # Print the 10 slowest examples and example groups at the 90 | # end of the spec run, to help surface which specs are running 91 | # particularly slow. 92 | config.profile_examples = 10 93 | 94 | # Run specs in random order to surface order dependencies. If you find an 95 | # order dependency and want to debug it, you can fix the order by providing 96 | # the seed, which is printed after each run. 97 | # --seed 1234 98 | config.order = :random 99 | 100 | # Seed global randomization in this process using the `--seed` CLI option. 101 | # Setting this allows you to use `--seed` to deterministically reproduce 102 | # test failures related to randomization by passing the same `--seed` value 103 | # as the one that triggered the failure. 104 | Kernel.srand config.seed 105 | =end 106 | end 107 | -------------------------------------------------------------------------------- /web/src/state/global_state.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import globalHook from "use-global-hook"; 3 | import { fromByteArray, toByteArray } from "base64-js"; 4 | import api from "../util/api"; 5 | import produce from "immer"; 6 | import simpleHash from "../util/hash"; 7 | import { groupBy, lastValueGroupReducer } from "../util/mungers"; 8 | 9 | const initialState = { 10 | variables: {}, 11 | settings: {}, 12 | screenMetadata: {}, 13 | bitmaps: {}, 14 | cachedBitmaps: {}, 15 | errors: [], 16 | loadedPages: {} 17 | }; 18 | 19 | function createLoadFunction(apiPath, stateVariable, fn = x => x) { 20 | return (store, { forceReload = false } = {}) => { 21 | if ( 22 | !forceReload && 23 | store.state[stateVariable] !== initialState[stateVariable] 24 | ) { 25 | return Promise.resolve(store.state[stateVariable]); 26 | } else { 27 | return api.get(apiPath).then(x => { 28 | const value = fn(x.data); 29 | const newState = { 30 | ...store.state, 31 | [stateVariable]: value, 32 | loadedPages: { 33 | ...store.state.loadedPages, 34 | [stateVariable]: true 35 | } 36 | }; 37 | store.setState(newState); 38 | return value; 39 | }); 40 | } 41 | }; 42 | } 43 | 44 | function createPaginatedLoadFunction( 45 | apiPath, 46 | stateVariable, 47 | fn = x => x 48 | ) { 49 | const fetchFn = (store, { forceReload = false, page = 0, others = {} } = {}) => { 50 | if ( 51 | !forceReload && 52 | store.state[stateVariable] !== initialState[stateVariable] 53 | ) { 54 | return Promise.resolve(store.state[stateVariable]); 55 | } else { 56 | const path = `${apiPath}?page=${page}`; 57 | 58 | return api.get(path).then(x => { 59 | const count = x.data.count; 60 | const value = fn(x.data); 61 | const newOthers = {...others, ...value}; 62 | 63 | if (Object.keys(newOthers).length < count) { 64 | return fetchFn(store, { forceReload, page: page+1, others: newOthers }); 65 | } else { 66 | store.setState({ 67 | ...store.state, 68 | [stateVariable]: newOthers, 69 | loadedPages: { 70 | ...store.state.loadedPages, 71 | [stateVariable]: true 72 | } 73 | }) 74 | return newOthers; 75 | } 76 | }) 77 | } 78 | }; 79 | 80 | return fetchFn; 81 | } 82 | 83 | const actions = { 84 | loadVariables: createPaginatedLoadFunction("/variables", "variables", x => x.variables), 85 | loadScreenMetadata: createLoadFunction("/screens", "screenMetadata"), 86 | loadSettings: createLoadFunction("/settings", "settings"), 87 | loadBitmaps: createLoadFunction("/bitmaps", "bitmaps", x => 88 | groupBy(x.bitmaps, v => v.name, { groupReducer: lastValueGroupReducer }) 89 | ), 90 | addError: (store, error) => { 91 | const updated = produce(store.state, draft => { 92 | draft.errors.push(error); 93 | }); 94 | store.setState(updated); 95 | }, 96 | dismissError: (store, index) => { 97 | const updated = produce(store.state, draft => { 98 | draft.errors.splice(index, 1); 99 | }); 100 | store.setState(updated); 101 | }, 102 | 103 | loadBitmap: (store, filename) => { 104 | const file = filename.split("/").slice(-1)[0]; 105 | const { [filename]: cachedFile } = store.state.cachedBitmaps; 106 | 107 | return actions.loadBitmaps(store).then(bitmaps => { 108 | if ( 109 | cachedFile && 110 | cachedFile.data && 111 | bitmaps[filename] && 112 | bitmaps[filename].metadata && 113 | bitmaps[filename].metadata.hash === cachedFile.hash 114 | ) { 115 | return Promise.resolve(toByteArray(cachedFile.data)); 116 | } else { 117 | return api 118 | .get(`/bitmaps/${file}`, { responseType: "arraybuffer" }) 119 | .then(x => { 120 | const data = new Uint8Array(x.data); 121 | const hash = simpleHash(data); 122 | 123 | const newState = produce(store.state, draft => { 124 | if (!store.state.cachedBitmaps[filename]) { 125 | draft.cachedBitmaps[filename] = { 126 | data: fromByteArray(data), 127 | hash 128 | }; 129 | } else { 130 | Object.assign(draft.cachedBitmaps[filename], { 131 | data: fromByteArray(data), 132 | hash 133 | }); 134 | } 135 | }); 136 | localStorage.setItem( 137 | "bitmap_cache", 138 | JSON.stringify(newState.cachedBitmaps) 139 | ); 140 | store.setState(newState); 141 | 142 | return x.data; 143 | }); 144 | } 145 | }); 146 | }, 147 | 148 | loadInitialState: store => { 149 | const promises = [ 150 | "loadSettings", 151 | "loadVariables", 152 | "loadScreenMetadata" 153 | ].map(x => actions[x](store)); 154 | 155 | return Promise.all(promises); 156 | } 157 | }; 158 | 159 | const loadInitialState = () => { 160 | try { 161 | return { 162 | ...initialState, 163 | cachedBitmaps: JSON.parse(localStorage.getItem("bitmap_cache")) || {} 164 | }; 165 | } catch (error) { 166 | console.warn("Error loading cached state from localStorage", error); 167 | return initialState; 168 | } 169 | }; 170 | 171 | const useGlobalState = globalHook(React, loadInitialState(), actions); 172 | 173 | export { useGlobalState }; 174 | export default useGlobalState; 175 | -------------------------------------------------------------------------------- /web/src/bitmaps/BitmapToolbar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | import Form from "react-bootstrap/Form"; 3 | import Button from "react-bootstrap/Button"; 4 | import { 5 | faSave, 6 | faPencilAlt, 7 | faFillDrip, 8 | faFolderOpen, 9 | faExpandArrowsAlt, 10 | faCheck, 11 | faTrash, 12 | faDownload, 13 | faUndo, 14 | faRedo 15 | } from "@fortawesome/free-solid-svg-icons"; 16 | 17 | import "./BitmapToolbar.scss"; 18 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 19 | import { useToggle } from "react-use"; 20 | import api from "../util/api"; 21 | import MemoizedFontAwesomeIcon from "../util/MemoizedFontAwesomeIcon"; 22 | 23 | const DimensionEditor = ({ label, value, onChange }) => { 24 | const _onChange = useCallback(e => onChange(e.target.value)); 25 | 26 | return ( 27 |
28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | const ToolSwitcher = ({ name, icon, onChange, isActive }) => { 35 | const onClick = useCallback(() => onChange(name), [name, onChange]); 36 | 37 | return ( 38 |
  • 39 | 42 |
  • 43 | ); 44 | }; 45 | 46 | const TOOLS = [ 47 | { name: "pencil", icon: faPencilAlt }, 48 | { name: "fill", icon: faFillDrip } 49 | // { name: "import", icon: faFileImage } 50 | ]; 51 | 52 | const COLORS = [ 53 | { name: "white", canvasColor: "rgb(255,255,255)", bitValue: 0 }, 54 | { name: "black", canvasColor: "rgb(0,0,0)", bitValue: 1 } 55 | ]; 56 | 57 | export default ({ 58 | width, 59 | height, 60 | bitmapDefinition, 61 | onResize, 62 | activeTool, 63 | onChangeTool, 64 | onSave, 65 | isLoaderActive, 66 | toggleLoaderActive, 67 | onDownload, 68 | color, 69 | setColor, 70 | onDelete, 71 | onUndo, 72 | onRedo, 73 | canUndo, 74 | canRedo 75 | }) => { 76 | const [isResizing, toggleResizing] = useToggle(false); 77 | const [resizeWidth, setResizeWidth] = useState(0); 78 | const [resizeHeight, setResizeHeight] = useState(0); 79 | 80 | useEffect(() => setResizeWidth(width), [width]); 81 | useEffect(() => setResizeHeight(height), [height]); 82 | 83 | const onSaveResize = useCallback(() => { 84 | onResize({ width: parseInt(resizeWidth), height: parseInt(resizeHeight) }); 85 | toggleResizing(); 86 | }, [resizeWidth, resizeHeight, onResize]); 87 | 88 | return ( 89 |
    90 | 91 |
      92 |
    • 93 | 96 |
    • 97 |
    • 98 | 105 |
    • 106 |
    • 107 | 114 |
    • 115 |
    • 116 | 119 |
    • 120 |
    • 121 | 124 |
    • 125 |
    126 | 127 |
    128 | 129 | 130 |
      131 |
    • 132 | 135 |
    • 136 |
    • 137 | 140 |
    • 141 |
    142 | 143 |
    144 | 145 | 146 |
      147 | {TOOLS.map(x => ( 148 | 155 | ))} 156 |
    157 | 158 |
    159 | 160 | 161 |
      162 | {COLORS.map(x => ( 163 |
    • 164 |
    • 171 | ))} 172 |
    173 | 174 |
    175 | 176 | {isResizing && ( 177 | <> 178 | 183 | 188 |
      189 |
    • 190 | 193 |
    • 194 |
    195 | 196 | )} 197 |
    198 | ); 199 | }; 200 | -------------------------------------------------------------------------------- /lib/MQTT/MqttClient.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | using namespace std::placeholders; 10 | 11 | #if defined(ESP32) 12 | // Structure borrowed from: 13 | // https://github.com/nkolban/esp32-snippets/blob/master/cpp_utils/FreeRTOSTimer.cpp 14 | static std::map timersMap; 15 | 16 | void MqttClient::internalCallback(TimerHandle_t xTimer) { 17 | MqttClient* client = timersMap.at(xTimer); 18 | client->connect(); 19 | } 20 | #elif defined(ESP8266) 21 | void MqttClient::internalCallback(MqttClient* client) { 22 | client->connect(); 23 | } 24 | #endif 25 | 26 | const char* MqttClient::CONNECTED_STATUS = "connected"; 27 | const char* MqttClient::DISCONNECTED_STATUS = "disconnected"; 28 | const char* MqttClient::STATUS_VARIABLE = "mqtt_state"; 29 | 30 | MqttClient::MqttClient( 31 | String domain, 32 | uint16_t port, 33 | String variableTopicPattern, 34 | String username, 35 | String password, 36 | String clientStatusTopic 37 | ) : port(port) 38 | , domain(domain) 39 | , username(username) 40 | , password(password) 41 | , lastConnectAttempt(0) 42 | , variableUpdateCallback(NULL) 43 | , topicPattern(variableTopicPattern) 44 | , clientStatusTopic(clientStatusTopic) 45 | { 46 | this->topicPatternBuffer = new char[topicPattern.length() + 1]; 47 | strcpy(this->topicPatternBuffer, this->topicPattern.c_str()); 48 | this->topicPatternTokens = new TokenIterator(this->topicPatternBuffer, topicPattern.length(), '/'); 49 | } 50 | 51 | MqttClient::~MqttClient() { 52 | delete this->topicPatternTokens; 53 | delete this->topicPatternBuffer; 54 | 55 | updateStatus(MqttClient::DISCONNECTED_STATUS); 56 | mqttClient.disconnect(); 57 | 58 | #if defined(ESP32) 59 | xTimerDelete(reconnectTimer, portMAX_DELAY); 60 | timersMap.erase(reconnectTimer); 61 | #endif 62 | } 63 | 64 | void MqttClient::onVariableUpdate(TVariableUpdateFn fn) { 65 | this->variableUpdateCallback = fn; 66 | } 67 | 68 | void MqttClient::begin() { 69 | #if defined(ESP32) 70 | reconnectTimer = xTimerCreate( 71 | "mqttTimer", 72 | pdMS_TO_TICKS(2000), 73 | pdFALSE, 74 | (void*)0, 75 | internalCallback 76 | ); 77 | timersMap.insert(std::make_pair(reconnectTimer, this)); 78 | #endif 79 | 80 | if (this->clientStatusTopic.length() > 0) { 81 | mqttClient.setWill( 82 | this->clientStatusTopic.c_str(), 83 | // QoS = 1 (at least once) 84 | 1, 85 | // retain = true 86 | true, 87 | MqttClient::DISCONNECTED_STATUS 88 | ); 89 | } 90 | 91 | // Setup callbacks 92 | mqttClient.onConnect(std::bind(&MqttClient::connectCallback, this, _1)); 93 | mqttClient.onDisconnect(std::bind(&MqttClient::disconnectCallback, this, _1)); 94 | mqttClient.onMessage(std::bind(&MqttClient::messageCallback, this, _1, _2, _3, _4, _5, _6)); 95 | mqttClient.setKeepAlive(60); 96 | 97 | // Configure client 98 | sprintf_P(this->clientName, PSTR("epaper-display-%u"), ESP_CHIP_ID()); 99 | mqttClient.setClientId(this->clientName); 100 | 101 | if (this->username.length() > 0) { 102 | mqttClient.setCredentials(this->username.c_str(), this->password.c_str()); 103 | } 104 | mqttClient.setServer(this->domain.c_str(), this->port); 105 | 106 | connect(); 107 | } 108 | 109 | void MqttClient::connect() { 110 | #ifdef MQTT_DEBUG 111 | Serial.println(F("MqttClient - connecting")); 112 | #endif 113 | 114 | mqttClient.connect(); 115 | } 116 | 117 | void MqttClient::disconnectCallback(AsyncMqttClientDisconnectReason reason) { 118 | #ifdef MQTT_DEBUG 119 | Serial.println(F("MqttClient - disconnected")); 120 | #endif 121 | 122 | if (variableUpdateCallback != nullptr) { 123 | this->variableUpdateCallback(STATUS_VARIABLE, DISCONNECTED_STATUS); 124 | } 125 | 126 | #if defined(ESP8266) 127 | reconnectTimer.once(2, internalCallback, this); 128 | #elif defined(ESP32) 129 | xTimerStart(reconnectTimer, 0); 130 | #endif 131 | } 132 | 133 | void MqttClient::connectCallback(bool sessionPresent) { 134 | if (this->topicPattern.length() > 0) { 135 | String topic = this->topicPattern; 136 | topic.replace(String(":") + MQTT_TOPIC_VARIABLE_NAME_TOKEN, "+"); 137 | 138 | #ifdef MQTT_DEBUG 139 | printf_P(PSTR("MqttClient - subscribing to topic: %s\n"), topic.c_str()); 140 | #endif 141 | 142 | mqttClient.subscribe(topic.c_str(), 0); 143 | } 144 | 145 | updateStatus(MqttClient::CONNECTED_STATUS); 146 | 147 | if (variableUpdateCallback != nullptr) { 148 | this->variableUpdateCallback(STATUS_VARIABLE, CONNECTED_STATUS); 149 | } 150 | } 151 | 152 | void MqttClient::updateStatus(const char* status) { 153 | if (this->clientStatusTopic.length() > 0) { 154 | mqttClient.publish( 155 | this->clientStatusTopic.c_str(), 156 | // QoS = 1 (at least once) 157 | 1, 158 | // retain = true 159 | true, 160 | status 161 | ); 162 | } 163 | } 164 | 165 | void MqttClient::messageCallback( 166 | char* topic, 167 | char* payload, 168 | AsyncMqttClientMessageProperties properties, 169 | size_t len, 170 | size_t index, 171 | size_t total 172 | ) { 173 | if (index > 0) { 174 | Serial.println(F("MqttClient - WARNING: got unsupported second call to messageCallback")); 175 | return; 176 | } 177 | 178 | char payloadCopy[len + 1]; 179 | memcpy(payloadCopy, payload, len); 180 | payloadCopy[len] = 0; 181 | 182 | #ifdef MQTT_DEBUG 183 | Serial.printf("MqttClient - Got message on topic: %s\n%s\n", topic, payloadCopy); 184 | #endif 185 | 186 | if (this->variableUpdateCallback != NULL) { 187 | TokenIterator topicItr(topic, strlen(topic), '/'); 188 | UrlTokenBindings urlTokens(*topicPatternTokens, topicItr); 189 | 190 | if (urlTokens.hasBinding(MQTT_TOPIC_VARIABLE_NAME_TOKEN)) { 191 | const char* variable = urlTokens.get(MQTT_TOPIC_VARIABLE_NAME_TOKEN); 192 | 193 | this->variableUpdateCallback(variable, payloadCopy); 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /web/src/templates/FormatterEditor.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from "react"; 2 | import Button from "react-bootstrap/Button"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { 5 | faPlus, 6 | faPencilAlt, 7 | faTrash, 8 | faSave 9 | } from "@fortawesome/free-solid-svg-icons"; 10 | import { useBoolean } from "react-use"; 11 | import Form from "react-jsonschema-form"; 12 | import { FormatterSchema, MarkedForDeletion } from "./schema"; 13 | import { BadgedText } from "./BadgedText"; 14 | import produce from "immer"; 15 | import { ArrayFieldTemplate } from "./ArrayFieldTemplate"; 16 | import MemoizedFontAwesomeIcon from "../util/MemoizedFontAwesomeIcon"; 17 | import Alert from "react-bootstrap/Alert"; 18 | 19 | const uiSchema = { 20 | formatter: { 21 | type: { 22 | "ui:enumDisabled": ["ref"] 23 | } 24 | }, 25 | "ui:ArrayFieldTemplate": ArrayFieldTemplate 26 | }; 27 | 28 | function FormatterForm({ initialState = {}, onSave, onCancel }) { 29 | const [formState, setFormState] = useState(initialState); 30 | 31 | const onChange = useCallback(e => { 32 | setFormState(e.formData); 33 | }, []); 34 | 35 | const onSubmit = useCallback( 36 | e => { 37 | onSave(formState); 38 | }, 39 | [onSave, formState] 40 | ); 41 | 42 | return ( 43 |
    52 |
    53 | 57 | 58 | 66 |
    67 |
    68 | ); 69 | } 70 | 71 | function FormatterListItem({ formatter, index, onEdit, onDelete }) { 72 | const _onEdit = useCallback( 73 | e => { 74 | e.preventDefault(); 75 | onEdit(index); 76 | }, 77 | [index, onEdit] 78 | ); 79 | 80 | const _onDelete = useCallback( 81 | e => { 82 | e.preventDefault(); 83 | 84 | if (confirm("Are you sure you want to delete this formatter?")) { 85 | onDelete(index); 86 | } 87 | }, 88 | [index, onDelete] 89 | ); 90 | 91 | const { formatter: def } = formatter; 92 | 93 | return ( 94 | 107 | ); 108 | } 109 | 110 | function FormatterList({ formatters, onEdit, onDelete }) { 111 | if (!Array.isArray(formatters)) { 112 | return ( 113 | 114 |

    115 | The formatter section has unexpected type. 116 |

    117 |

    118 | Should be an Array, but is:{" "} 119 | {typeof formatters} 120 |

    121 |
    122 | ); 123 | } 124 | 125 | return ( 126 |
      127 | {formatters.map((x, i) => ( 128 | 129 | {x !== MarkedForDeletion && ( 130 |
    • 131 | 137 |
    • 138 | )} 139 |
      140 | ))} 141 |
    142 | ); 143 | } 144 | 145 | export function FormatterEditor({ value, onUpdate }) { 146 | const [editing, setEditing] = useState(null); 147 | 148 | const onNew = useCallback(() => { 149 | if (!value.formatters) { 150 | onUpdate( 151 | produce(value, draft => { 152 | draft.formatters = []; 153 | }) 154 | ); 155 | } 156 | setEditing("new"); 157 | }, [value]); 158 | 159 | const onEdit = useCallback( 160 | index => { 161 | setEditing(index); 162 | }, 163 | [value] 164 | ); 165 | 166 | const onSave = useCallback( 167 | data => { 168 | const updated = produce(value, draft => { 169 | if (editing === "new") { 170 | draft.formatters.push(data); 171 | } else { 172 | Object.assign(draft.formatters[editing], data); 173 | } 174 | }); 175 | onUpdate(updated); 176 | setEditing(null); 177 | }, 178 | [value, onUpdate, editing] 179 | ); 180 | 181 | const onDelete = useCallback(index => { 182 | const updated = produce(value, draft => { 183 | draft.formatters.splice(index, 1, MarkedForDeletion); 184 | }); 185 | onUpdate(updated); 186 | }); 187 | 188 | return ( 189 | <> 190 | {editing !== null && ( 191 | setEditing(null)} 195 | /> 196 | )} 197 | {editing === null && ( 198 | <> 199 | 204 |
    205 | 209 |
    210 | 211 | )} 212 | 213 | ); 214 | } 215 | -------------------------------------------------------------------------------- /lib/Display/DisplayTemplateDriver.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | #if defined(ESP32) 15 | #include 16 | extern "C" { 17 | #include "freertos/semphr.h" 18 | } 19 | #endif 20 | 21 | #ifndef _DRIVER_H 22 | #define _DRIVER_H 23 | 24 | #ifndef TEXT_BOUNDING_BOX_PADDING 25 | #define TEXT_BOUNDING_BOX_PADDING 5 26 | #endif 27 | 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | #include 35 | #include 36 | #include 37 | #include 38 | 39 | #include 40 | #include 41 | 42 | typedef std::function 43 | VariableUpdateObserverFn; 44 | 45 | typedef const String& TRegionId; 46 | typedef const String& TVariableName; 47 | typedef const String& TVariableValue; 48 | 49 | typedef std::function 50 | RegionUpdateObserverFn; 51 | 52 | class DisplayTemplateDriver { 53 | public: 54 | DisplayTemplateDriver(GxEPD2_GFX* display, Settings& settings); 55 | 56 | // Updates the value for the given variable, and marks any regions bound to 57 | // that variable as dirty. 58 | void updateVariable(const String& name, const String& value); 59 | void deleteVariable(const String& name); 60 | String getVariable(const String& name); 61 | void clearVariables(); 62 | 63 | // Helper to resolve variable values (used in REST API) 64 | void resolveVariables(JsonArray toResolve, JsonArray response); 65 | // Helper to return all current variable values 66 | void dumpRegionValues(JsonObject response); 67 | 68 | // Sets the JSON template to load from SPIFFS. Clears any regions that may 69 | // have been parsed from the previous template. 70 | void setTemplate(const String& filename); 71 | const String& getTemplateFilename(); 72 | 73 | // Performs a full update of the display. Applies the template and refreshes 74 | // the entire screen. 75 | void fullUpdate(); 76 | 77 | // Performs a full update on the next call of loop(). 78 | void scheduleFullUpdate(); 79 | 80 | // Updates the display by checking for regions that have been marked as dirty 81 | // and performing partial updates on the bounding boxes. 82 | void loop(); 83 | 84 | // Registers fn as an observer when a variable changes 85 | void onVariableUpdate(VariableUpdateObserverFn fn); 86 | 87 | // Registers fn as an observer when a region changes 88 | void onRegionUpdate(RegionUpdateObserverFn fn); 89 | 90 | // Renders a bitmap. Using this instead of AdafruitGFX#renderBitmap to remove 91 | // the restriction that widths be a multiple of 8. 92 | static void drawBitmap(GxEPD2_GFX* display, 93 | uint8_t* bitmap, 94 | size_t x, 95 | size_t y, 96 | size_t w, 97 | size_t h, 98 | uint16_t color, 99 | uint16_t backgroundColor); 100 | 101 | void init(); 102 | 103 | private: 104 | GxEPD2_GFX* display; 105 | VariableDictionary vars; 106 | String templateFilename; 107 | Settings& settings; 108 | String newTemplate; 109 | VariableUpdateObserverFn onVariableUpdateFn; 110 | RegionUpdateObserverFn onRegionUpdateFn; 111 | 112 | DoublyLinkedList> regions; 113 | 114 | bool dirty; 115 | bool shouldFullUpdate; 116 | time_t lastFullUpdate; 117 | 118 | #if defined(ESP32) 119 | SemaphoreHandle_t mutex; 120 | #endif 121 | 122 | const uint16_t defaultColor = GxEPD_BLACK; 123 | const uint16_t defaultBackgroundColor = GxEPD_WHITE; 124 | const GFXfont* defaultFont = &FreeSans9pt7b; 125 | 126 | void flushDirtyRegions(bool screenUpdates); 127 | void clearDirtyRegions(); 128 | void printError(const char* message); 129 | void loadTemplate(const String& templateFilename); 130 | 131 | void renderLines(JsonArray lines); 132 | void renderRectangles(VariableFormatterFactory& formatterFactory, 133 | JsonArray lines, 134 | uint16_t backgroundColor); 135 | void renderTexts(VariableFormatterFactory& formatterFactory, 136 | JsonObject updateRects, 137 | JsonArray text, 138 | uint16_t backgroundColor); 139 | void renderBitmaps(VariableFormatterFactory& formatterFactory, 140 | JsonArray bitmaps, 141 | uint16_t templateBackground); 142 | void renderBitmap(const String& filename, 143 | uint16_t x, 144 | uint16_t y, 145 | uint16_t w, 146 | uint16_t h, 147 | uint16_t color, 148 | uint16_t backgroundColor); 149 | 150 | std::shared_ptr addTextRegion( 151 | JsonArray bitmaps, uint16_t backgroundColor); 152 | 153 | std::shared_ptr addTextRegion(uint16_t x, 154 | uint16_t y, 155 | uint16_t color, 156 | uint16_t backgroundColor, 157 | const GFXfont* font, 158 | uint8_t textSize, 159 | std::shared_ptr formatter, 160 | JsonObject updateRects, 161 | JsonObject spec, 162 | uint16_t index); 163 | std::shared_ptr addBitmapRegion(uint16_t x, 164 | uint16_t y, 165 | uint16_t w, 166 | uint16_t h, 167 | uint16_t color, 168 | uint16_t backgroundColor, 169 | VariableFormatterFactory& formatterFactory, 170 | JsonObject spec, 171 | uint16_t index); 172 | std::shared_ptr addRectangleRegion( 173 | VariableFormatterFactory& formatterFactory, 174 | JsonObject spec, 175 | uint16_t index, 176 | uint16_t backgroundColor); 177 | 178 | const uint16_t parseColor(const String& colorName); 179 | const GFXfont* parseFont(const String& fontName); 180 | const uint16_t extractColor(JsonObject spec); 181 | const uint16_t extractBackgroundColor( 182 | JsonObject spec, uint16_t templateBackground); 183 | const uint8_t extractTextSize(JsonObject spec); 184 | 185 | static bool regionContainedIn( 186 | Rectangle& r, DoublyLinkedList& others); 187 | }; 188 | 189 | #endif 190 | --------------------------------------------------------------------------------