Merge branch 'main' of github.com:rvbglas/esp_clock github/master
authorRoman Bazalevskiy <rvb@rvb.name>
Fri, 18 Nov 2022 17:05:44 +0000 (20:05 +0300)
committerRoman Bazalevskiy <rvb@rvb.name>
Fri, 18 Nov 2022 17:05:44 +0000 (20:05 +0300)
22 files changed:
Clock.h [new file with mode: 0644]
ESP8266_clock.ino [new file with mode: 0644]
alarm.cpp [new file with mode: 0644]
config.cpp [new file with mode: 0644]
data/config.txt [new file with mode: 0644]
data/web/icon.svg [new file with mode: 0644]
data/web/icons.woff2 [new file with mode: 0644]
data/web/index.html [new file with mode: 0644]
data/web/manifest.json [new file with mode: 0644]
data/web/script.js [new file with mode: 0644]
data/web/style.css [new file with mode: 0644]
description [new file with mode: 0644]
esp-clock.ino [new file with mode: 0644]
fonts.h [new file with mode: 0644]
hardware.cpp [new file with mode: 0644]
make_fs.sh [new file with mode: 0644]
net.cpp [new file with mode: 0644]
panel.cpp [new file with mode: 0644]
time.cpp [new file with mode: 0644]
ui.yml [new file with mode: 0644]
weather.cpp [new file with mode: 0644]
web.cpp [new file with mode: 0644]

diff --git a/Clock.h b/Clock.h
new file mode 100644 (file)
index 0000000..cd3620d
--- /dev/null
+++ b/Clock.h
@@ -0,0 +1,201 @@
+#pragma once
+#include <ESP8266WiFi.h>
+#include <Printable.h>
+#include <LittleFS.h>
+#include <RTClib.h>
+
+// net.cpp
+
+extern bool isNetConnected;
+
+void setupNet(bool AP = false);
+void tickNet();
+
+// time.cpp
+
+extern bool isTimeSet;
+extern time_t now;
+extern time_t last_sync;
+
+extern int hh;
+extern int mi;
+extern int ss;
+
+extern int dw;
+
+extern int dd;
+extern int mm;
+extern int yy;
+
+void registerTimeHandler(const char* handlerName, const char handlerType, std::function<void()> timeHandler);
+void unregisterTimeHandler(const char* handlerName);
+void registerTimeHandler(const __FlashStringHelper* handlerName, const char handlerType, std::function<void()> timeHandler);
+void unregisterTimeHandler(const __FlashStringHelper* handlerName);
+void setupHandlers();
+
+void setupTime();
+void tickTime();
+
+bool isNight();
+
+// config.cpp
+
+char* copystr(const char* s);
+char* copystr(const __FlashStringHelper* s);
+
+union ValueType {
+  bool boolValue;
+  int intValue;
+  double floatValue;
+  char *charValue;
+};
+
+class ConfigParameter: public Printable {
+  public:
+    
+    ConfigParameter(const char *id, const char* value);
+    ConfigParameter(const char *id, int value);
+    ConfigParameter(const char *id, double value);
+    ConfigParameter(const char *id, bool value);
+    ConfigParameter(const __FlashStringHelper* id, const char* value);
+    ConfigParameter(const __FlashStringHelper* id, int value);
+    ConfigParameter(const __FlashStringHelper* id, double value);
+    ConfigParameter(const __FlashStringHelper* id, bool value);
+    ~ConfigParameter();
+    
+    const char *getID() const;
+    char  getType() const;
+    const bool getBoolValue() const;
+    const int getIntValue() const ;
+    const double getFloatValue() const;
+    const char *getCharValue() const;
+    
+    void  setValue(const bool value);
+    void  setValue(const int value);
+    void  setValue(const double value);
+    void  setValue(const char *value);
+
+    virtual size_t printTo(Print& p) const;
+  
+  protected:
+    void init(const char *id, char type);
+    void init(const __FlashStringHelper *id, char type);
+    
+  private:
+    const char *_id;
+    char  _type; // _B_oolean, _I_nt, double _F_loat, _S_tring
+    ValueType _value;
+};
+
+class Config: public Printable {
+  public:
+
+    Config();
+    ~Config();
+
+    ConfigParameter* getParameter(int i);
+    int   getParametersCount() const;
+
+    void   setValue(const char *id, const char *value);
+    void   setValue(const char *id, int value);
+    void   setValue(const char *id, double value);
+    void   setValue(const char *id, bool value);
+    int    getIntValue(const char *id) const;
+    double getFloatValue(const char *id) const;
+    bool   getBoolValue(const char *id) const;
+    const char* getCharValue(const char *id) const;
+
+    void   setValue(const __FlashStringHelper* id, const char *value);
+    void   setValue(const __FlashStringHelper* id, int value);
+    void   setValue(const __FlashStringHelper* id, double value);
+    void   setValue(const __FlashStringHelper* id, bool value);
+    int    getIntValue(const __FlashStringHelper* id) const;
+    double getFloatValue(const __FlashStringHelper* id) const ;
+    bool   getBoolValue(const __FlashStringHelper* id) const;
+    const char* getCharValue(const __FlashStringHelper* id) const;
+
+    ConfigParameter* getParam(const char *id) const;
+    ConfigParameter* getParam(const __FlashStringHelper* id) const;
+
+    virtual size_t printTo(Print& p) const;
+
+    void readFrom(Stream& s);
+    void dumpJson(Stream& s) const;
+
+    void clear();
+
+    unsigned long getTimestamp();
+    void resetTimestamp();
+
+  protected:
+    void init();
+    void addParameter(ConfigParameter* p);
+
+  private:
+    int         _paramsCount;
+    int         _max_params;
+    unsigned long _timestamp;
+    ConfigParameter** _params;
+  
+};
+
+extern Config cfg;
+void setupConfig();
+void saveConfig(bool force = false);
+void reboot();
+void reset();
+
+// hardware.cpp
+
+extern bool isRTCEnabled;
+extern RTC_DS3231 RTC;
+
+void setupHardware();
+void tickHardware();
+
+void beep(int tone, int length, int beep_ms = 60000, int silent_ms = 0);
+
+// panel.cpp
+
+#define mDefault 0
+#define mTime 1
+#define mDate 2
+#define mWeather 3
+#define mBarrier 4
+#define mMessage 5
+#define mLast 6
+
+extern int screenMode;
+extern int screenModeRequested;
+
+void setupPanel();
+void tickPanel();
+
+void utf8rus(const char* source, char* target, int maxLen = 255);
+
+void message(const char* str, int priority=10);
+void message(const __FlashStringHelper*  str, int priority=10);
+void messageModal(const char* str);
+void messageModal(const __FlashStringHelper*  str);
+void scroll(const char* str, bool force = false);
+void setPanelBrightness();
+
+// alarm.cpp
+
+void setupAlarm();
+
+// weather.cpp
+
+extern char weatherData[256];
+
+void setupWeatherRequest();
+
+// web.cpp
+
+extern bool isApEnabled;
+
+void setupWeb();
+void tickWeb();
+void reportChange(const __FlashStringHelper* name);
+void reportMessage(const __FlashStringHelper* msg);
+void sendWeather();
diff --git a/ESP8266_clock.ino b/ESP8266_clock.ino
new file mode 100644 (file)
index 0000000..b8e8c4a
--- /dev/null
@@ -0,0 +1,50 @@
+#include "Clock.h"
+#include <LittleFS.h>
+
+void setup() {
+  // put your setup code here, to run once:
+
+  Serial.begin(115200);
+  Serial.println();
+  Serial.println(F("Starting..."));
+
+  setupConfig();
+  Serial.println(cfg);
+
+  setupHandlers();
+
+  setupHardware();
+  setupPanel();
+
+  setupNet();
+  setupTime();
+
+  setupAlarm();
+  setupWeatherRequest();
+
+  setupWeb();
+}
+
+void mem() {
+  Serial.println(F("-------------------------------------------------------------"));
+  Serial.print("Heap:"); Serial.print(ESP.getFreeHeap()); 
+  Serial.print(" Largest chunk:"); Serial.print(ESP.getMaxFreeBlockSize());
+  Serial.print(" Fragmentation:"); Serial.print(ESP.getHeapFragmentation());
+  Serial.print(" Stack:"); Serial.println(ESP.getFreeContStack());
+  Serial.println(F("-------------------------------------------------------------"));
+}
+
+void loop() {
+  static unsigned long lastMillis = 0;
+  int interval = 15000;
+  // put your main code here, to run repeatedly:
+  if (millis() - lastMillis > interval) {
+    lastMillis = millis();
+    mem();
+  }
+  tickNet();
+  tickTime();
+  tickHardware();
+  tickPanel();
+  tickWeb();
+}
diff --git a/alarm.cpp b/alarm.cpp
new file mode 100644 (file)
index 0000000..2964418
--- /dev/null
+++ b/alarm.cpp
@@ -0,0 +1,37 @@
+#include "Clock.h"
+
+void hourlyBeep() {
+  bool enable_hourly = cfg.getBoolValue(F("enable_hourly"));
+  int hourly_count = cfg.getIntValue(F("hourly_count"));
+  bool hourly_night = cfg.getBoolValue(F("hourly_night"));
+  int hourly_beep_ms = cfg.getIntValue(F("hourly_beep_ms"));
+  int hourly_silent_ms = cfg.getIntValue(F("hourly_silent_ms"));
+  int hourly_tone = cfg.getIntValue(F("hourly_tone"));  
+  if ( enable_hourly && hourly_count && (!isNight() || hourly_night)) {
+    int length = hourly_count * hourly_beep_ms + (hourly_count - 1) * hourly_silent_ms;
+    beep(hourly_tone, length, hourly_beep_ms, hourly_silent_ms);
+  }
+}
+
+void checkAlarm() {
+  bool enable_alarm = cfg.getBoolValue(F("enable_alarm"));
+  int alarm_hour = cfg.getIntValue(F("alarm_hour"));
+  int alarm_minute = cfg.getIntValue(F("alarm_minute"));
+  const char* alarm_days = cfg.getCharValue(F("alarm_days"));
+  int alarm_tone = cfg.getIntValue(F("alarm_tone"));
+  int alarm_length = cfg.getIntValue(F("alarm_length"));
+  int alarm_beep_ms = cfg.getIntValue(F("alarm_beep_ms"));
+  int alarm_silent_ms = cfg.getIntValue(F("alarm_silent_ms"));
+  if ( enable_alarm && (hh == alarm_hour) && (mi == alarm_minute) && (alarm_days[dw?dw-1:6]!='0')) {
+    beep(alarm_tone, alarm_length * 1000, alarm_beep_ms, alarm_silent_ms);
+    reportMessage(F("Будильник сработал!"));
+  }
+}
+
+void setupAlarm() {
+  unregisterTimeHandler(F("hourly-beep"));
+  unregisterTimeHandler(F("check-alarm"));
+  registerTimeHandler(F("hourly-beep"),'h',hourlyBeep);
+  registerTimeHandler(F("check-alarm"),'m',checkAlarm);
+}
+
diff --git a/config.cpp b/config.cpp
new file mode 100644 (file)
index 0000000..9c46b95
--- /dev/null
@@ -0,0 +1,566 @@
+#include "Clock.h"
+#include <Print.h>
+
+#define MAX_PARAMS 64
+
+Config cfg;
+
+char* copystr(const char* s) {
+  int len = strlen(s);
+  char* res = new char[len+1];
+  strcpy(res, s);
+  return res;
+}
+
+char* copystr(const __FlashStringHelper* s) {
+  int len = strlen_P((PGM_P)s);
+  char* res = new char[len+1];
+  strcpy_P(res, (PGM_P)s);
+  return res;
+}
+
+ConfigParameter::ConfigParameter(const char *id, const char* value) {
+  init(id, 'S');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const char *id, int value) {
+  init(id, 'I');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const char *id, double value) {
+  init(id, 'F');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const char *id, bool value) {
+  init(id, 'B');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const __FlashStringHelper *id, const char* value) {
+  init(id, 'S');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const __FlashStringHelper *id, int value) {
+  init(id, 'I');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const __FlashStringHelper *id, double value) {
+  init(id, 'F');
+  setValue(value);
+};
+
+ConfigParameter::ConfigParameter(const __FlashStringHelper *id, bool value) {
+  init(id, 'B');
+  setValue(value);
+};
+
+ConfigParameter::~ConfigParameter() {
+  delete[] _id;
+  if (_type=='S' && _value.charValue) {
+    delete[] _value.charValue;
+  }
+};
+
+const char *ConfigParameter::getID() const {
+  return _id;
+};
+
+char  ConfigParameter::getType() const {
+  return _type;
+};
+
+const bool ConfigParameter::getBoolValue() const {
+  return (_type=='B')?_value.boolValue:false;
+};
+
+const int ConfigParameter::getIntValue() const {
+  return (_type=='I')?_value.intValue:0;
+};
+
+const double ConfigParameter::getFloatValue() const {
+  return (_type=='F')?_value.floatValue:0.0;
+};
+
+const char *ConfigParameter::getCharValue() const {
+  return (_type=='S')?_value.charValue:"";
+};
+
+void  ConfigParameter::setValue(const bool value) {
+  if (_type=='B') {
+    _value.boolValue = value;
+  }
+};
+
+void  ConfigParameter::setValue(const int value) {
+  if (_type=='I') {
+    _value.intValue = value;
+  }
+};
+
+void  ConfigParameter::setValue(const double value) {
+  if (_type=='F') {
+    _value.floatValue = value;
+  }
+};
+
+void  ConfigParameter::setValue(const char *value) {
+  if (_type=='S') {
+    int length = strlen(value);
+    if (!_value.charValue) {
+      _value.charValue  = new char[length + 1];
+    } else if (strlen(_value.charValue) != length) {
+      delete[] _value.charValue;
+      _value.charValue  = new char[length + 1];
+    }
+    strcpy(_value.charValue,value);
+  }
+};
+
+void ConfigParameter::init(const char *id, char type) {
+  _id = copystr(id);
+  _type = type;
+  _value.charValue = nullptr;
+};
+
+void ConfigParameter::init(const __FlashStringHelper *id, char type) {
+  _id = copystr(id);
+  _type = type;
+  _value.charValue = nullptr;
+};
+
+size_t ConfigParameter::printTo(Print& p) const {
+  size_t n = 0;
+  n += p.print(_id); n += p.print(":"); n += p.print(_type); n += p.print(":");
+  switch (_type) {
+    case 'B':
+      if (_value.boolValue) { n += p.print("true"); }
+      else { n += p.print("false"); }
+      break;
+    case 'I':
+      n += p.print(_value.intValue);
+      break;
+    case 'F':
+      n += p.print(_value.floatValue);
+      break;
+    case 'S':
+      n += p.print(_value.charValue);
+      break;
+  }
+  return n;
+}
+
+Config::Config() {
+  init();
+}
+
+void Config::init() {
+  _paramsCount = 0;
+  _max_params = MAX_PARAMS;
+  _params = NULL;
+  _timestamp = 0;
+}
+
+Config::~Config() {
+  if (_params != NULL) {
+    for (int i = 0; i < _paramsCount; i++) {
+      delete _params[i];
+      _params[i] = nullptr;
+    }
+    free(_params);
+    _params = NULL;
+  }
+}
+
+ConfigParameter* Config::getParameter(int i) {
+  return _params[i];
+}
+
+int Config::getParametersCount() const {
+  return _paramsCount;
+}
+
+ConfigParameter* Config::getParam(const char *id) const {
+  for (int j = 0; j < _paramsCount; j++) {
+    if (strcmp(id, _params[j]->getID()) == 0) {
+      return _params[j];
+    }
+  }
+  return nullptr;
+}
+
+ConfigParameter* Config::getParam(const __FlashStringHelper *id) const {
+  for (int j = 0; j < _paramsCount; j++) {
+    if (strcmp_P(_params[j]->getID(),(PGM_P)id) == 0) {
+      return _params[j];
+    }
+  }
+  return nullptr;
+}
+
+void Config::addParameter(ConfigParameter* p) {
+  if (_params == NULL) {
+    _params = (ConfigParameter**)malloc(_max_params * sizeof(ConfigParameter*));
+  }
+  if (_paramsCount == _max_params) {
+    _max_params += MAX_PARAMS;
+    ConfigParameter** new_params = (ConfigParameter**)realloc(_params, _max_params * sizeof(ConfigParameter*));
+    if (new_params != NULL) {
+      _params = new_params;
+    } else {
+      Serial.println(F("Не удалось расширить массив параметров"));
+      return;
+    }
+  }
+  _params[_paramsCount] = p;
+  _paramsCount++;
+}
+
+
+void Config::setValue(const char *id, const char *value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(id,value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const __FlashStringHelper *id, const char *value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(copystr(id),value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const char *id, int value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(id,value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const __FlashStringHelper *id, int value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(copystr(id),value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const char *id, const double value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(id,value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const __FlashStringHelper *id, const double value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(copystr(id),value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const char *id, const bool value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(id,value));
+  }
+  _timestamp = now;
+}
+
+void Config::setValue(const __FlashStringHelper *id, const bool value) {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    p->setValue(value);
+  } else {
+    addParameter(new ConfigParameter(copystr(id),value));
+  }
+  _timestamp = now;
+}
+
+size_t Config::printTo(Print& p) const {
+  size_t n = 0;
+  for (int i=0; i<_paramsCount; i++) {
+    ConfigParameter* param = _params[i];
+    if (param) {
+      n += p.print(*param); n+= p.println();
+    }
+  }
+  return n;
+}
+
+int Config::getIntValue(const char *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getIntValue();
+  } else {
+    return 0;
+  }
+}
+
+int Config::getIntValue(const __FlashStringHelper *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getIntValue();
+  } else {
+    return 0;
+  }
+}
+
+double Config::getFloatValue(const char *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getFloatValue();
+  } else {
+    return 0.0;
+  }
+};
+
+double Config::getFloatValue(const __FlashStringHelper *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getFloatValue();
+  } else {
+    return 0.0;
+  }
+};
+
+bool Config::getBoolValue(const char *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getBoolValue();
+  } else {
+    return false;
+  }
+};
+
+bool Config::getBoolValue(const __FlashStringHelper *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getBoolValue();
+  } else {
+    return false;
+  }
+};
+
+const char* Config::getCharValue(const char *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getCharValue();
+  } else {
+    return "";
+  }
+};
+
+const char* Config::getCharValue(const __FlashStringHelper *id) const {
+  ConfigParameter* p = getParam(id);
+  if (p) {
+    return p->getCharValue();
+  } else {
+    return "";
+  }
+};
+
+void Config::readFrom(Stream& s) {
+  char buf[256]; // name + type + value + separators
+  while (s.available()) {
+    int x = s.readBytesUntil('\n',buf,255);
+    buf[x] = 0;
+    if (x>=3) {
+      char* origName = strtok(buf,":\r\n");
+      char* origType = strtok(NULL,":\r\n");
+      char* origValue = strtok(NULL,"\r\n");
+      if (origType && origName && origName[0]) {
+        if (origType[0]=='B') {
+          if (origValue) {
+            setValue(copystr(origName),(strcmp(origValue,"true")==0));
+          } else {
+            setValue(copystr(origName),false);
+          }
+        } else if (origType[0]=='I') {
+          if (origValue) {
+            setValue(copystr(origName),atoi(origValue));
+          } else {
+            setValue(copystr(origName),0);
+          }
+        } else if (origType[0]=='F') {
+          if (origValue) {
+            setValue(copystr(origName),atof(origValue));
+          } else {
+            setValue(copystr(origName),0.0);
+          }
+        } else if (origType[0]=='S') {
+          if (origValue) {
+            setValue(copystr(origName),origValue);
+          } else {
+            setValue(copystr(origName),"");
+          }
+        }
+      }
+    }
+  }
+}
+
+void Config::clear() {
+  if (_params != NULL) {
+    for (int i = 0; i < _paramsCount; i++) {
+      delete _params[i];
+      _params[i] = nullptr;
+    }
+    free(_params);
+  }
+  init();
+}
+
+void Config::dumpJson(Stream& s) const {
+  s.print("{");
+  for (int i=0; i<_paramsCount; i++) {
+    ConfigParameter* param = _params[i];
+    if (i) s.print(",");
+    s.print("\"");
+    s.print(param->getID());
+    s.print("\":");
+    switch (param->getType()) {
+      case 'B':
+        s.print(param->getBoolValue()?"true":"false");
+        break;
+      case 'I':
+        s.print(param->getIntValue());
+        break;
+      case 'F':
+        s.print(param->getFloatValue());
+        break;
+      case 'S':
+        s.print("\"");
+        s.print(param->getCharValue());
+        s.print("\"");
+        break;
+    }
+  }
+  s.print("}");
+}
+
+unsigned long Config::getTimestamp() {
+  return _timestamp;
+}
+
+void Config::resetTimestamp() {
+  _timestamp = 0;
+}
+
+void setupConfig() {
+  if (LittleFS.begin()) {
+    if (File f = LittleFS.open(F("/config.txt"),"r")) {
+      Serial.println('Открываю файл конфигурации');
+      cfg.readFrom(f);
+      f.close();
+      cfg.resetTimestamp();
+    }
+  } else {
+    Serial.println(F("Конфигурация не найдена"));
+  }
+}
+
+void saveConfig(bool force) {
+  if (cfg.getTimestamp() || force) {
+    if (File f = LittleFS.open(F("/config.txt"),"w")) {
+      f.print(cfg);
+      f.close();
+      Serial.println(F("Конфигурация сохранена"));
+      cfg.resetTimestamp();
+    }
+  }
+}
+
+void reboot() {
+  saveConfig();
+  LittleFS.end();
+  messageModal(F("Перезагружаюсь"));
+  ESP.restart();
+}
+
+char* distConfig PROGMEM =
+"sta_ssid:S:\n"
+"sta_psk:S:\n"
+"sta_wait:I:120\n"
+"ap_ssid:S:WiFi_Clock\n"
+"ap_psk:S:1234-5678\n"
+"ntp_server:S:ru.pool.ntp.org\n"
+"tz:S:MSK-3\n"
+"pin_sda:I:4\n"
+"pin_scl:I:5\n"
+"i2c_speed:I:400000\n"
+"enable_rtc:B:true\n"
+"flash_dots:B:true\n"
+"pin_din:I:12\n"
+"pin_clk:I:15\n"
+"pin_cs:I:13\n"
+"led_modules:I:4\n"
+"panel_font:I:1\n"
+"panel_speed:I:15\n"
+"panel_brightness_day:I:1\n"
+"panel_brightness_night:I:0\n"
+"panel_zero:B:true\n"
+"panel_seconds:B:false\n"
+"enable_button:B:true\n"
+"button_pin:I:0\n"
+"enable_buzzer:B:true\n"
+"buzzer_pin:I:14\n"
+"buzzer_passive:B:true\n"
+"day_from:I:8\n"
+"night_from:I:22\n"
+"enable_hourly:B:true\n"
+"hourly_night:B:false\n"
+"hourly_count:I:2\n"
+"hourly_tone:I:800\n"
+"hourly_beep_ms:I:100\n"
+"hourly_silent_ms:I:100\n"
+"enable_alarm:B:false\n"
+"alarm_hour:I:13\n"
+"alarm_minute:I:12\n"
+"alarm_days:S:1111111\n"
+"alarm_tone:I:1500\n"
+"alarm_length:I:60\n"
+"alarm_beep_ms:I:300\n"
+"alarm_silent_ms:I:200\n"
+"enable_weather:B:false\n"
+"weather_url:S:http://api.openweathermap.org/data/2.5/weather?lat=5{LATITUDE}&lon={LONGITUDE}&appid={API_KEY}&lang=ru&units=metric\n"
+"weather_template:S:Сегодня %weather.[0].description%, температура %main.temp% (%main.feels_like%)°C, влажность %main.humidity%%%, ветер %wind.speed% м/с\n"
+"weather_min:I:15\n"
+"auth_user:S:\n"
+"auth_pwd:S:\n";
+
+
+void reset() {
+  messageModal(F("Сбрасываю настройки"));
+  delay(2000);
+  if (File f = LittleFS.open(F("/config.txt"),"w")) {
+    f.print(distConfig);
+    f.close();
+  }
+  LittleFS.end();
+  ESP.restart();
+}
diff --git a/data/config.txt b/data/config.txt
new file mode 100644 (file)
index 0000000..c8442a8
--- /dev/null
@@ -0,0 +1,50 @@
+sta_ssid:S:
+sta_psk:S:
+sta_wait:I:120
+ap_ssid:S:WiFi_Clock
+ap_psk:S:1234-5678
+ntp_server:S:ru.pool.ntp.org
+tz:S:MSK-3
+pin_sda:I:4
+pin_scl:I:5
+i2c_speed:I:400000
+enable_rtc:B:true
+flash_dots:B:true
+pin_din:I:12
+pin_clk:I:15
+pin_cs:I:13
+led_modules:I:4
+panel_font:I:1
+panel_speed:I:15
+panel_brightness_day:I:1
+panel_brightness_night:I:0
+panel_zero:B:true
+panel_seconds:B:false
+enable_button:B:true
+button_pin:I:0
+button_inversed:B:false
+enable_buzzer:B:true
+buzzer_pin:I:14
+buzzer_passive:B:true
+day_from:I:8
+night_from:I:22
+enable_hourly:B:true
+hourly_night:B:false
+hourly_count:I:2
+hourly_tone:I:800
+hourly_beep_ms:I:100
+hourly_silent_ms:I:100
+enable_alarm:B:true
+alarm_hour:I:13
+alarm_minute:I:12
+alarm_days:S:1111100
+alarm_tone:I:1500
+alarm_length:I:60
+alarm_beep_ms:I:300
+alarm_silent_ms:I:200
+enable_weather:B:true
+weather_url:S:http://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={ap-key}&lang=ru&units=metric
+weather_template:S:Сегодня %weather.[0].description%, температура %main.temp% (%main.feels_like%)°C, влажность %main.humidity%%%, ветер %wind.speed% м/с
+weather_min:I:15
+auth_user:S:
+auth_pwd:S:
diff --git a/data/web/icon.svg b/data/web/icon.svg
new file mode 100644 (file)
index 0000000..913fff7
--- /dev/null
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1"  xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 40 40" style="enable-background:new 0 0 40 40;" xml:space="preserve">
+<g>
+       <path style="fill:#8BB7F0;" d="M20,38.5C9.799,38.5,1.5,30.201,1.5,20S9.799,1.5,20,1.5S38.5,9.799,38.5,20S30.201,38.5,20,38.5z"
+               />
+       <g>
+               <path style="fill:#4E7AB5;" d="M20,2c9.925,0,18,8.075,18,18s-8.075,18-18,18S2,29.925,2,20S10.075,2,20,2 M20,1
+                       C9.507,1,1,9.507,1,20s8.507,19,19,19s19-8.507,19-19S30.493,1,20,1L20,1z"/>
+       </g>
+</g>
+<g>
+       <path style="fill:#FFFFFF;" d="M20,35.5c-8.547,0-15.5-6.953-15.5-15.5S11.453,4.5,20,4.5S35.5,11.453,35.5,20S28.547,35.5,20,35.5
+               z"/>
+       <path style="fill:#4E7AB5;" d="M20,5c8.271,0,15,6.729,15,15s-6.729,15-15,15S5,28.271,5,20S11.729,5,20,5 M20,4
+               C11.163,4,4,11.163,4,20s7.163,16,16,16s16-7.163,16-16S28.837,4,20,4L20,4z"/>
+</g>
+<polyline style="fill:none;stroke:#66798F;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" points="26.995,9.665 20,20 
+       25.846,25.846 "/>
+<g>
+       <circle style="fill:#66798F;" cx="20" cy="20" r="1.5"/>
+</g>
+<g>
+       <circle style="fill:#C5D4DE;" cx="20" cy="7" r="1"/>
+</g>
+<g>
+       <circle style="fill:#C5D4DE;" cx="20" cy="33" r="1"/>
+</g>
+<g>
+       <circle style="fill:#C5D4DE;" cx="33" cy="20" r="1"/>
+</g>
+<g>
+       <circle style="fill:#C5D4DE;" cx="7" cy="20" r="1"/>
+</g>
+</svg>
diff --git a/data/web/icons.woff2 b/data/web/icons.woff2
new file mode 100644 (file)
index 0000000..85a75f5
Binary files /dev/null and b/data/web/icons.woff2 differ
diff --git a/data/web/index.html b/data/web/index.html
new file mode 100644 (file)
index 0000000..0688a49
--- /dev/null
@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="description" content="">
+    <title></title>
+    <link rel="stylesheet" href="/style.css">
+    <link rel="shortcut icon" href="/icon.svg">
+</head>
+<body>
+
+<div id="layout">
+  <!-- Menu toggle -->
+  <a href="#menu" id="menuLink" class="menu-link">
+    <!-- Hamburger icon -->
+    <span id="menuBtn"></span>
+  </a>
+
+  <div id="menu">
+    <div class="pure-menu">
+      <a id="_ui_menu_header" class="pure-menu-heading" href="/"></a>
+      <ul id="_ui_menu_list" class="pure-menu-list">
+      </ul>
+    </div>
+    <footer id="_ui_contacts"></footer>
+  </div>
+
+  <div id="_ui_notification" class="notification" hidden></div>
+  <div class="message fadeout" id="_ui_message" hidden>
+    <div class="message-text" id="_ui_message_text"></div>
+    <div class="close-button" onclick="closeMsg()" title="Закрыть">Х</div>
+  </div>
+
+  <div id="main">
+    <div class="header">
+      <h1 id="_ui_page_header"></h1>
+    </div>
+    <div class="content">
+      <form class="pure-form pure-form-stacked">
+        <fieldset>
+          <div class="pure-g" id="_ui_page_content">
+          </div>
+        </fieldset>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script src="/script.js"></script>
+
+</body>
+</html>
diff --git a/data/web/manifest.json b/data/web/manifest.json
new file mode 100644 (file)
index 0000000..50f32fd
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "background_color":"#fff",
+  "display":"standalone",
+  "start_url":"/",
+  "name":"IoT Clock",
+  "short_name":"IoT Clock",
+  "icons":[
+    {"src":"/icon.svg","sizes":"192x192","type":"image/svg"}
+    ]
+}
diff --git a/data/web/script.js b/data/web/script.js
new file mode 100644 (file)
index 0000000..ee43439
--- /dev/null
@@ -0,0 +1,489 @@
+var pages
+var parameters = {}
+var daynames = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] 
+var msgT
+
+function toggleMenu(e) {
+  active = (document.getElementById('menuLink').className.indexOf('active') !== -1)
+  if (active || e.target.id == 'menuLink' || e.target.id == 'menuBtn') {
+    elements = [ document.getElementById('layout'), document.getElementById('menu'), document.getElementById('menuLink') ]
+    for (const element of elements) {
+      if (!active) {
+        element.classList.add('active')
+      } else {
+        element.classList.remove('active')
+      }
+    }
+    if (e.target.id == 'menuLink' || e.target.id == 'menuBtn') {
+      e.preventDefault()
+    }
+    e.stopPropagation()
+  }
+}
+
+document.getElementById('layout').addEventListener('click', toggleMenu)
+
+function encode(r){
+  r = String(r)
+  return r.replace(/[\x26\x0A\<>'"]/g,function(r){return"&#"+r.charCodeAt(0)+";"})
+}
+
+function getAnchor() {
+    return window.location.hash.slice(1);
+}
+
+function drawHeader(project) {
+  var menu_header = document.getElementById('_ui_menu_header')
+  menu_header.innerHTML = project.name
+  document.title = project.name + '/' + project.version
+}
+
+function parseJsonQ(url, callback) {
+  var req = new XMLHttpRequest();
+
+  req.onreadystatechange = function () {
+    if (this.readyState != 4) return; 
+    if (this.status != 200 && this.status != 500 && this.status != 404) {
+      setTimeout(parseJsonQ(url, callback),30000);
+      return;
+    }
+    var json = JSON.parse(this.responseText)
+    callback(json);
+  };
+
+  req.open("GET", url, true);
+  req.send()
+
+}
+
+function updateElement(id, value) {
+  var element = document.getElementById("_ui_element_"+id)
+  if (!element) return;
+  var ui_class = element.dataset.ui_class;
+  switch (ui_class) {
+    case "table":
+      element.innerText = value
+      break
+    case "input":
+    case "password":
+    case "select":
+    case "number":
+    case "range":
+      element.value = value  
+      break
+    case "checkbox":
+      element.checked = value
+      break
+    case "week":
+      for (i=0; i<7; i++) {
+        var subname = '_ui_elpart_'+i+'_'+id
+        var subelement = document.getElementById(subname) 
+        if (value[i]==1) { 
+          subelement.className = "weekday-selected"
+        } else {
+          subelement.className = "weekday"
+        }
+      }
+      break
+  }  
+}
+
+function updateValues(json) {
+  for (var key in json) {
+    var obj = document.getElementById("_ui_element_"+key)
+    if (obj) {
+      updateElement(key, json[key])
+    }
+    parameters[key] = json[key]
+  }
+  var notification = document.getElementById('_ui_notification');
+  if (parameters['_changed']) {
+    notification.innerHTML = '<input type="button" id="save" value="Сохранить" class="pure-button" onclick="sendAction(\'save\')">'
+    notification.removeAttribute('hidden')
+  } else {
+    notification.innerHTML = ''
+    notification.hidden = true
+  }
+}
+
+function sendUpdate(id) {
+  var input = document.getElementById('_ui_element_'+id)
+  var ui_class = input.dataset.ui_class;
+  switch (ui_class) {
+    case 'input':
+    case 'password':
+    case 'number':
+    case 'range':
+      if (input.checkValidity() && input.value != parameters[id]) {
+        parseJsonQ('/config/set?name=' + id + '&value=' + encodeURIComponent(input.value), function(json) {
+          updateValues(json)
+        })
+      }
+      break
+    case 'select':
+      parseJsonQ('/config/set?name=' + id + '&value=' + encodeURIComponent(input.selectedOptions[0].value), function(json) {
+        updateValues(json)
+      })
+      break;
+    case 'checkbox':
+      parseJsonQ('/config/set?name=' + id + '&value=' + (input.checked?'true':'false'), function(json) {
+        updateValues(json)
+      })      
+      break;
+    case 'week':        
+      parseJsonQ('/config/set?name=' + id + '&value=' + input.dataset.value, function(json) {
+        updateValues(json)
+      })      
+      break;
+  }
+}
+
+function sendAction(name, params = {}) {
+  var url = '/action?name=' + name
+  for (var param in params) {
+    url += '&'+param+'='+encodeURIComponent(params[param])
+  }
+  parseJsonQ(url, function(json) {
+    if (json.result == 'FAILED') {
+      alert(json.message)
+      if (json.page) {
+        drawPage(json.page)
+      }
+    } else {
+      location.reload()
+    }
+  })
+}
+
+function showPwd(id) {
+  var x = document.getElementById('_ui_element_' + id)
+  if (x.type === "password") {
+    x.type = "text";
+  } else {
+    x.type = "password";
+  }
+}
+
+function openSelect(id) {
+  var selector = document.getElementById('_ui_elemmodal_'+id);
+  selector.removeAttribute("hidden")
+}
+
+function closeSelect(id) {
+  var selector = document.getElementById('_ui_elemmodal_'+id);
+  selector.hidden = true
+}
+
+function closeMsg() {
+  document.getElementById("_ui_message").hidden = true;
+}
+
+function fadeMsg() {
+  var msg = document.getElementById('_ui_message');
+  msg.classList.add("fadeout")
+  msgT = setTimeout(()=> { closeMsg() }, 5000);
+}
+
+function openMsg(msgText) {
+  document.getElementById("_ui_message_text").innerText = msgText;
+  document.getElementById("_ui_message").classList.remove("fadeout"); 
+  document.getElementById("_ui_message").removeAttribute('hidden');
+  
+  if (msgT) { 
+    window.clearTimeout(msgT); 
+  }
+  msgT = setTimeout(()=> { fadeMsg(); }, 5000);
+}
+
+function selectWiFi(id, ssid) {
+  closeSelect(id);
+  var x = document.getElementById('_ui_element_' + id)
+  updateElement(x,ssid);
+  sendUpdate(id)
+}
+
+function getWiFi(id) {
+  var list = document.getElementById('_ui_elemselect_'+id)
+
+  var req = new XMLHttpRequest();
+
+  req.onreadystatechange = function () {
+    if (this.readyState != 4) return; 
+    if (this.status != 200 && this.status != 500 && this.status != 404) {
+      setTimeout(getWiFi(id),30000);
+      return;
+    }
+    var json = JSON.parse(this.responseText)
+    var table = '<table cellpadding="5" border="0" align="center"><thead class="table-header"><tr><td>SSID</td><td>BSSID</td><td>RSSI</td><td>Канал</td><td>Защита</td></tr></thead><tbody>'
+    if (!json.length) {
+      setTimeout(getWiFi(id),5000);
+    }
+    for (idx in json) {
+      var encryption = json[idx].secure == 2? "TKIP" : json[idx].secure == 5? "WEP" : json[idx].secure == 4? "CCMP" : json[idx].secure == 7? "нет" : json[idx].secure == 8? "Автоматически" : "Не определено";
+      table += '<tr onclick="selectWiFi(\''+id+'\',\''+json[idx].ssid+'\')"><td>'+json[idx].ssid+'</td><td>'+json[idx].bssid+'</td><td>'+json[idx].rssi+'</td><td>'+json[idx].channel+'</td><td>'+encryption+'</td></tr>'
+    }
+    
+    table += '</tbody></table>'
+    list.innerHTML = table;
+  };
+
+  req.open("GET", "/wifi/scan", true);
+  req.send()
+}
+
+function clickDay(id, day) {
+  value = parameters[id].split('')
+  day_value = value[day]
+  day_value = (day_value=='0')?'1':'0'
+  value[day] = day_value
+  element = document.getElementById('_ui_element_' + id)
+  value = value.join('')
+  element.dataset.value = value;
+  sendUpdate(id);
+}
+
+function sendTime(id) {
+  var value = document.getElementById('_ui_element_' + id).value
+  if (value) {
+    var date = new Date(value)
+    var timestamp = Math.floor(date.getTime()/1000);
+    sendAction('time',{"timestamp":timestamp})
+  }
+}
+
+function downloadFile(url, name) {
+  const a = document.createElement('a')
+  a.href = url
+  a.download = name?name:url.split('/').pop()
+  document.body.appendChild(a)
+  a.click()
+  document.body.removeChild(a)
+}
+
+function uploadConfig() {
+  var elem = document.getElementById('_config_file')
+  if (elem.value) {
+    var data = elem.files[0]
+    console.log(data)
+    fetch('/config/put', {method:'PUT',body:data});
+    elem.value = null
+  }
+}
+
+function elementHTML(element) {
+  var value
+  if (parameters[element.id] || !isNaN(parameters[element.id])) {
+    value = parameters[element.id]
+  } else if (element.value) {
+    value = element.value
+  } else {
+    value = ""
+  }
+  switch (element.type) {
+    case 'hr':
+      return '<div class="pure-u-1 pure-u-md-1-3"><hr></div>'
+    case 'button':
+      return '<div class="pure-u-1 pure-u-md-1-3"><div align="center"><input type="button" id="' 
+        + element.id + '" value="' + encode(element.label) + '" class="pure-button" onclick="sendAction(\'' + element.id + '\')" /></div></div>'
+    case 'password':
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<div class="hinted"><input data-ui_class="password" type="password" id="_ui_element_' + element.id + '" value="' + encode(value) 
+        + '" class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" />'
+        + '<z class="hint" onclick="showPwd(\'' + element.id + '\')">&#61550;</z></div></div>'
+    case 'input':
+      var pattern = ""
+      if (element.pattern) {
+        pattern = ' pattern="' + encode(element.pattern) + '"'
+      }
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<input data-ui_class="input" type="text" id="_ui_element_' + element.id + '" value="' + encode(value) +'"'+ pattern
+        + ' class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" /></div>'
+    case 'input-wifi':
+      var pattern = ""
+      if (element.pattern) {
+        pattern = ' pattern="' + encode(element.pattern) + '"'
+      }
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<div class="hinted"><input data-ui_class="input" type="text" id="_ui_element_' + element.id + '" value="' + encode(value) +'"'+ pattern
+        + ' class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" />' 
+        + '<z class="hint" onclick="openSelect(\'' + element.id+ '\'); getWiFi(\'' + element.id + '\')">&#61931;</z></div>'
+        + '<div class="modal" id="_ui_elemmodal_' + element.id + '" hidden>' 
+        + '<div class="modal-content">'
+        + '<div id="_ui_elemselect_' + element.id + '"></div>'
+        + '<div class="pure-u-1 pure-u-md-1-3"><div align="center"><input type="button" id="_ui_button_' 
+        + element.id + '" value="Закрыть" class="pure-button" onclick="closeSelect(\'' + element.id + '\')"></div>'
+        + '</div></div></div></div>'
+    case 'checkbox':
+      return '<div class="pure-u-1 pure-u-md-1-3"><label class="switch socket" for="_ui_element_' + element.id + '">'
+        + '<input class="switch" data-ui_class="checkbox" type="checkbox" id="_ui_element_' + element.id + '"' + (parameters[element.id]?' checked':'') + ' onchange="sendUpdate(\'' + element.id + '\')" />'
+        + '<span class="switch slider">'+ encode(element.label) + '</span>'
+        + '</label></div>'
+    case 'select':
+      var options = '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<select class="pure-u-24-24" data-ui_class="select" id="_ui_element_' + element.id + '" onchange="sendUpdate(\'' + element.id + '\')">'
+      for (const option of element.options) {
+        var list_option = '<option value="' + encode(option.value) + '" ';
+        if (option.value == parameters[element.id]) {
+          list_option += 'selected '
+        }
+        list_option += '>' + encode(option.text) + '</option>'
+        options += list_option
+      }
+      options += '</select></div>'
+      return options
+    case 'week':
+      days = '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>' 
+        + '<table data-ui_class="week" data-value="' + value + '" id="_ui_element_' + element.id + '" cellpadding="5" border="0" class="week"><tbody><tr>'
+      for (i=0; i<7; i++) {
+        a_enabled = (value[i] == "1")
+        days = days + '<td><div class="weekday' + (a_enabled?"-selected":"") + '" id="_ui_elpart_'+i+'_'+element.id+'" onclick="clickDay(\'' + element.id + '\', ' + i + ')">'+ daynames[i] + '</div></td>'
+      }
+      days += '</tr></tbody></table></div>'
+      return days
+    case 'timeset':
+      var now = new Date()
+      now.setMinutes(now.getMinutes() - now.getTimezoneOffset())
+      value = now.toISOString().slice(0, -1);
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        +'<input id="_ui_element_'+element.id+'" data-ui_class="timeset" class="inline-input" type="datetime-local" value="'+value+'">'
+        + '<div class="send-button" onclick="sendTime(\''+element.id+'\')">-></div></div>'
+    case 'text':
+      return '<div class="pure-u-1 pure-u-md-1-3"><h2 id="_ui_element_'+ element.id +'" ' + (element.color?'style="color:'+ element.color+'" ':'')+ '>' + encode(value) + '</h2></div>'
+    case 'number':
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<input data-ui_class="number" type="number" '+ (!isNaN(element.min)?'min="'+element.min+'" ':'') + (!isNaN(element.max)?'max="'+element.max+'" ':'') + (!isNaN(element.step)?'step="'+element.step+'" ':'')
+        + 'id="_ui_element_' + element.id + '" value="' + encode(value) +'"'+ pattern
+        + ' class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" /></div>'
+    case 'range':
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<input data-ui_class="range" type="range" '+ (!isNaN(element.min)?'min="'+element.min+'" ':'') + (!isNaN(element.max)?'max="'+element.max+'" ':'') + (!isNaN(element.step)?'step="'+element.step+'" ':'')
+        + 'id="_ui_element_' + element.id + '" value="' + encode(value) +'"'+ pattern
+        + ' class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" /></div>'
+    case 'config':
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        + '<div align="center"><input type="button" class="pure-button row-button" value="Сохранить..." onclick="downloadFile(\'/config/get\',\'config.json\')">'
+        + '<label for="_config_file" class="pure-button row-button">Восстановить...</label>'
+        + '<input type="button" class="pure-button row-button" value="Настройки по умолчанию" onclick="confirm(\'Вы точно хотите сбросить настройки?\')?sendAction(\'reset\'):console.log(\'Не надо так не надо...\')">'
+        + '<input type="file" id="_config_file" onchange="uploadConfig()" style="visibility:hidden">'
+        + '</div></div>'
+    case 'table':
+    default:
+      return '<div class="pure-u-1 pure-u-md-1-3"><table class="texttable" cellpadding="5" border="0" align="center"><tbody><tr><td class="value-name" align="right">' 
+        + encode(element.label)+ '</td><td id="_ui_element_' 
+        + element.id + '" data-ui_class="table" ' + (element.color?'style="color:'+ element.color+'" ':'')+ '>' + encode(value) + '</td></tr></tbody></table></div>'
+  }
+}
+
+function drawPage(id) {
+  var idx =0, i=0
+  for (const page of pages) {
+    var menu_link = document.getElementById('_ui_pglink_' + page.id)  
+    if (page.id != id) {
+      menu_link.classList.remove("pure-menu-selected")
+    } else {
+      menu_link.classList.add("pure-menu-selected")
+      idx = i
+    }
+    i++
+  }
+  var page_header = document.getElementById("_ui_page_header")
+  page_header.innerHTML = pages[idx].title
+  var page_content = document.getElementById("_ui_page_content");
+  var content = ''
+  for (const element of pages[idx].elements) {
+    content = content + elementHTML(element) + '\n'
+  }
+  page_content.innerHTML = content
+  window.location.hash = id
+}
+
+function drawNavigator(project, pages) {
+  var menu = document.getElementById('_ui_menu_list');
+  var list = ''
+  for (const page of pages) {
+    list = list + '<li id="_ui_pglink_' + page.id 
+      + '" class="pure-menu-item"><a class="pure-menu-link" onclick="drawPage(\''+ page.id +'\')" href="#' + page.id + '">'
+      + (page.icon?'<span class="icon">'+page.icon+'</span>':'')
+      + page.title+'</a></li>'
+  }
+  menu.innerHTML = list
+}
+
+function drawContacts(contacts) {
+  if (!contacts) return;
+  var contact_list = '<hr><h4 class="pure-u1">Контакты</h4>'
+  for (contact of contacts) {
+    const url = new URL(contact)
+    var ref
+    switch (url.protocol) {
+      case 'http':
+      case 'https:':
+        ref = '<span class="icon">&#61461;</span>'+url.hostname
+        break
+      case 'mailto:':
+        ref = '<span class="icon">&#61664;</span>'+url.pathname
+        break
+      case 'tg:':
+        ref = '<span class="icon">&#62150;</span>'+url.pathname
+        contact = 'tg://resolve?domain='+url.pathname
+        break
+      default:
+        ref = '<span class="icon">&#62074;</span>'+url.pathname
+    }
+    contact_list += '<a href="'+contact+'">'+ref+'</a>'
+  }
+  var footer = document.getElementById('_ui_contacts');
+  footer.innerHTML = contact_list
+}
+
+function drawUI(ui) {
+  drawHeader(ui.project)
+  pages = ui.pages
+  drawNavigator(ui.project, pages)
+  drawContacts(ui.project.contacts)
+  var anchor = getAnchor()
+  if (anchor) {
+    drawPage(anchor)
+  } else {
+    drawPage(pages[0].id)
+  }
+}
+
+function GetUI() {
+  parseJsonQ("/ui", drawUI);
+}
+
+GetUI()
+
+function initES() {
+  if (!!window.EventSource) {
+    var source = new EventSource('/events');
+    openMsg('Соединение установлено')
+
+    source.onerror = function(e) {
+      if (source.readyState == 2) {
+        openMsg('Соединение прервано')
+        setTimeout(initES, 5000);
+      }
+    };
+        
+    source.addEventListener('update', function(e) {
+      updateValues(JSON.parse(e.data));
+    }, false);
+    source.addEventListener('message', function(e) {
+      openMsg(e.data);
+    }, false);
+  }
+}
+
+initES();
+
+function drawConfig(cfg) {
+  updateValues(cfg)
+}
+
+function GetCfg() {
+  parseJsonQ("/config/get", drawConfig);
+}
+
+GetCfg()
diff --git a/data/web/style.css b/data/web/style.css
new file mode 100644 (file)
index 0000000..243c659
--- /dev/null
@@ -0,0 +1,1502 @@
+/*!
+Pure v3.0.0
+Copyright 2013 Yahoo!
+Licensed under the BSD License.
+https://github.com/pure-css/pure/blob/master/LICENSE
+*/
+/*!
+normalize.css v | MIT License | https://necolas.github.io/normalize.css/
+Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html {
+ line-height:1.15;
+ -webkit-text-size-adjust:100%
+}
+body {
+ margin:0
+}
+main {
+ display:block
+}
+h1 {
+ font-size:2em;
+ margin:.67em 0
+}
+hr {
+ box-sizing:content-box;
+ height:0;
+ overflow:visible
+}
+pre {
+ font-family:monospace,monospace;
+ font-size:1em
+}
+a {
+ background-color:transparent
+}
+abbr[title] {
+ border-bottom:none;
+ text-decoration:underline;
+ -webkit-text-decoration:underline dotted;
+ text-decoration:underline dotted
+}
+b,
+strong {
+ font-weight:bolder
+}
+code,
+kbd,
+samp {
+ font-family:monospace,monospace;
+ font-size:1em
+}
+small {
+ font-size:80%
+}
+sub,
+sup {
+ font-size:75%;
+ line-height:0;
+ position:relative;
+ vertical-align:baseline
+}
+sub {
+ bottom:-.25em
+}
+sup {
+ top:-.5em
+}
+img {
+ border-style:none
+}
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family:inherit;
+ font-size:100%;
+ line-height:1.15;
+ margin:0
+}
+button,
+input {
+ overflow:visible
+}
+button,
+select {
+ text-transform:none
+}
+[type=button],
+[type=reset],
+[type=submit],
+button {
+ -webkit-appearance:button
+}
+[type=button]::-moz-focus-inner,
+[type=reset]::-moz-focus-inner,
+[type=submit]::-moz-focus-inner,
+button::-moz-focus-inner {
+ border-style:none;
+ padding:0
+}
+[type=button]:-moz-focusring,
+[type=reset]:-moz-focusring,
+[type=submit]:-moz-focusring,
+button:-moz-focusring {
+ outline:1px dotted ButtonText
+}
+fieldset {
+ padding:.35em .75em .625em
+}
+legend {
+ box-sizing:border-box;
+ color:inherit;
+ display:table;
+ max-width:100%;
+ padding:0;
+ white-space:normal
+}
+progress {
+ vertical-align:baseline
+}
+textarea {
+ overflow:auto
+}
+[type=checkbox],
+[type=radio] {
+ box-sizing:border-box;
+ padding:0
+}
+[type=number]::-webkit-inner-spin-button,
+[type=number]::-webkit-outer-spin-button {
+ height:auto
+}
+[type=search] {
+ -webkit-appearance:textfield;
+ outline-offset:-2px
+}
+[type=search]::-webkit-search-decoration {
+ -webkit-appearance:none
+}
+::-webkit-file-upload-button {
+ -webkit-appearance:button;
+ font:inherit
+}
+details {
+ display:block
+}
+summary {
+ display:list-item
+}
+template {
+ display:none
+}
+[hidden] {
+ display:none
+}
+html {
+ font-family:sans-serif
+}
+.hidden,
+[hidden] {
+ display:none!important
+}
+.pure-img {
+ max-width:100%;
+ height:auto;
+ display:block
+}
+.pure-g {
+ display:flex;
+ flex-flow:row wrap;
+ align-content:flex-start
+}
+.pure-u {
+ display:inline-block;
+ vertical-align:top
+}
+.pure-u-1,
+.pure-u-1-1,
+.pure-u-1-12,
+.pure-u-1-2,
+.pure-u-1-24,
+.pure-u-1-3,
+.pure-u-1-4,
+.pure-u-1-5,
+.pure-u-1-6,
+.pure-u-1-8,
+.pure-u-10-24,
+.pure-u-11-12,
+.pure-u-11-24,
+.pure-u-12-24,
+.pure-u-13-24,
+.pure-u-14-24,
+.pure-u-15-24,
+.pure-u-16-24,
+.pure-u-17-24,
+.pure-u-18-24,
+.pure-u-19-24,
+.pure-u-2-24,
+.pure-u-2-3,
+.pure-u-2-5,
+.pure-u-20-24,
+.pure-u-21-24,
+.pure-u-22-24,
+.pure-u-23-24,
+.pure-u-24-24,
+.pure-u-3-24,
+.pure-u-3-4,
+.pure-u-3-5,
+.pure-u-3-8,
+.pure-u-4-24,
+.pure-u-4-5,
+.pure-u-5-12,
+.pure-u-5-24,
+.pure-u-5-5,
+.pure-u-5-6,
+.pure-u-5-8,
+.pure-u-6-24,
+.pure-u-7-12,
+.pure-u-7-24,
+.pure-u-7-8,
+.pure-u-8-24,
+.pure-u-9-24 {
+ display:inline-block;
+ letter-spacing:normal;
+ word-spacing:normal;
+ vertical-align:top;
+ text-rendering:auto
+}
+.pure-u-1-24 {
+ width:4.1667%
+}
+.pure-u-1-12,
+.pure-u-2-24 {
+ width:8.3333%
+}
+.pure-u-1-8,
+.pure-u-3-24 {
+ width:12.5%
+}
+.pure-u-1-6,
+.pure-u-4-24 {
+ width:16.6667%
+}
+.pure-u-1-5 {
+ width:20%
+}
+.pure-u-5-24 {
+ width:20.8333%
+}
+.pure-u-1-4,
+.pure-u-6-24 {
+ width:25%
+}
+.pure-u-7-24 {
+ width:29.1667%
+}
+.pure-u-1-3,
+.pure-u-8-24 {
+ width:33.3333%
+}
+.pure-u-3-8,
+.pure-u-9-24 {
+ width:37.5%
+}
+.pure-u-2-5 {
+ width:40%
+}
+.pure-u-10-24,
+.pure-u-5-12 {
+ width:41.6667%
+}
+.pure-u-11-24 {
+ width:45.8333%
+}
+.pure-u-1-2,
+.pure-u-12-24 {
+ width:50%
+}
+.pure-u-13-24 {
+ width:54.1667%
+}
+.pure-u-14-24,
+.pure-u-7-12 {
+ width:58.3333%
+}
+.pure-u-3-5 {
+ width:60%
+}
+.pure-u-15-24,
+.pure-u-5-8 {
+ width:62.5%
+}
+.pure-u-16-24,
+.pure-u-2-3 {
+ width:66.6667%
+}
+.pure-u-17-24 {
+ width:70.8333%
+}
+.pure-u-18-24,
+.pure-u-3-4 {
+ width:75%
+}
+.pure-u-19-24 {
+ width:79.1667%
+}
+.pure-u-4-5 {
+ width:80%
+}
+.pure-u-20-24,
+.pure-u-5-6 {
+ width:83.3333%
+}
+.pure-u-21-24,
+.pure-u-7-8 {
+ width:87.5%
+}
+.pure-u-11-12,
+.pure-u-22-24 {
+ width:91.6667%
+}
+.pure-u-23-24 {
+ width:95.8333%
+}
+.pure-u-1,
+.pure-u-1-1,
+.pure-u-24-24,
+.pure-u-5-5 {
+ width:100%
+}
+.pure-button {
+ display:inline-block;
+ line-height:normal;
+ white-space:nowrap;
+ vertical-align:middle;
+ text-align:center;
+ cursor:pointer;
+ -webkit-user-drag:none;
+ -webkit-user-select:none;
+ user-select:none;
+ box-sizing:border-box
+}
+.pure-button::-moz-focus-inner {
+ padding:0;
+ border:0
+}
+.pure-button-group {
+ letter-spacing:-.31em;
+ text-rendering:optimizespeed
+}
+.opera-only :-o-prefocus,
+.pure-button-group {
+ word-spacing:-0.43em
+}
+.pure-button-group .pure-button {
+ letter-spacing:normal;
+ word-spacing:normal;
+ vertical-align:top;
+ text-rendering:auto
+}
+.pure-button {
+ font-family:inherit;
+ font-size:100%;
+ padding:.5em 1em;
+ color:rgba(0,0,0,.8);
+ border:none transparent;
+ background-color:#e6e6e6;
+ text-decoration:none;
+ border-radius:2px
+}
+.pure-button-hover,
+.pure-button:focus,
+.pure-button:hover {
+ background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))
+}
+.pure-button:focus {
+ outline:0
+}
+.pure-button-active,
+.pure-button:active {
+ box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;
+ border-color:#000
+}
+.pure-button-disabled,
+.pure-button-disabled:active,
+.pure-button-disabled:focus,
+.pure-button-disabled:hover,
+.pure-button[disabled] {
+ border:none;
+ background-image:none;
+ opacity:.4;
+ cursor:not-allowed;
+ box-shadow:none;
+ pointer-events:none
+}
+.pure-button-hidden {
+ display:none
+}
+.pure-button-primary,
+.pure-button-selected,
+a.pure-button-primary,
+a.pure-button-selected {
+ background-color:#0078e7;
+ color:#fff
+}
+.pure-button-group .pure-button {
+ margin:0;
+ border-radius:0;
+ border-right:1px solid rgba(0,0,0,.2)
+}
+.pure-button-group .pure-button:first-child {
+ border-top-left-radius:2px;
+ border-bottom-left-radius:2px
+}
+.pure-button-group .pure-button:last-child {
+ border-top-right-radius:2px;
+ border-bottom-right-radius:2px;
+ border-right:none
+}
+.pure-form input[type=color],
+.pure-form input[type=date],
+.pure-form input[type=datetime-local],
+.pure-form input[type=datetime],
+.pure-form input[type=email],
+.pure-form input[type=month],
+.pure-form input[type=number],
+.pure-form input[type=password],
+.pure-form input[type=search],
+.pure-form input[type=tel],
+.pure-form input[type=text],
+.pure-form input[type=time],
+.pure-form input[type=url],
+.pure-form input[type=week],
+.pure-form select,
+.pure-form textarea {
+ padding:.5em .6em;
+ display:inline-block;
+ border:1px solid #ccc;
+ box-shadow:inset 0 1px 3px #ddd;
+ border-radius:4px;
+ vertical-align:middle;
+ box-sizing:border-box
+}
+.pure-form input:not([type]) {
+ padding:.5em .6em;
+ display:inline-block;
+ border:1px solid #ccc;
+ box-shadow:inset 0 1px 3px #ddd;
+ border-radius:4px;
+ box-sizing:border-box
+}
+.pure-form input[type=color] {
+ padding:.2em .5em
+}
+.pure-form input[type=color]:focus,
+.pure-form input[type=date]:focus,
+.pure-form input[type=datetime-local]:focus,
+.pure-form input[type=datetime]:focus,
+.pure-form input[type=email]:focus,
+.pure-form input[type=month]:focus,
+.pure-form input[type=number]:focus,
+.pure-form input[type=password]:focus,
+.pure-form input[type=search]:focus,
+.pure-form input[type=tel]:focus,
+.pure-form input[type=text]:focus,
+.pure-form input[type=time]:focus,
+.pure-form input[type=url]:focus,
+.pure-form input[type=week]:focus,
+.pure-form select:focus,
+.pure-form textarea:focus {
+ outline:0;
+ border-color:#129fea
+}
+.pure-form input:not([type]):focus {
+ outline:0;
+ border-color:#129fea
+}
+.pure-form input[type=checkbox]:focus,
+.pure-form input[type=file]:focus,
+.pure-form input[type=radio]:focus {
+ outline:thin solid #129FEA;
+ outline:1px auto #129FEA
+}
+.pure-form .pure-checkbox,
+.pure-form .pure-radio {
+ margin:.5em 0;
+ display:block
+}
+.pure-form input[type=color][disabled],
+.pure-form input[type=date][disabled],
+.pure-form input[type=datetime-local][disabled],
+.pure-form input[type=datetime][disabled],
+.pure-form input[type=email][disabled],
+.pure-form input[type=month][disabled],
+.pure-form input[type=number][disabled],
+.pure-form input[type=password][disabled],
+.pure-form input[type=search][disabled],
+.pure-form input[type=tel][disabled],
+.pure-form input[type=text][disabled],
+.pure-form input[type=time][disabled],
+.pure-form input[type=url][disabled],
+.pure-form input[type=week][disabled],
+.pure-form select[disabled],
+.pure-form textarea[disabled] {
+ cursor:not-allowed;
+ background-color:#eaeded;
+ color:#cad2d3
+}
+.pure-form input:not([type])[disabled] {
+ cursor:not-allowed;
+ background-color:#eaeded;
+ color:#cad2d3
+}
+.pure-form input[readonly],
+.pure-form select[readonly],
+.pure-form textarea[readonly] {
+ background-color:#eee;
+ color:#777;
+ border-color:#ccc
+}
+.pure-form input:focus:invalid,
+.pure-form select:focus:invalid,
+.pure-form textarea:focus:invalid {
+ color:#b94a48;
+ border-color:#e9322d
+}
+.pure-form input[type=checkbox]:focus:invalid:focus,
+.pure-form input[type=file]:focus:invalid:focus,
+.pure-form input[type=radio]:focus:invalid:focus {
+ outline-color:#e9322d
+}
+.pure-form select {
+ height:2.25em;
+ border:1px solid #ccc;
+ background-color:#fff
+}
+.pure-form select[multiple] {
+ height:auto
+}
+.pure-form label {
+ margin:.5em 0 .2em
+}
+.pure-form fieldset {
+ margin:0;
+ padding:.35em 0 .75em;
+ border:0
+}
+.pure-form legend {
+ display:block;
+ width:100%;
+ padding:.3em 0;
+ margin-bottom:.3em;
+ color:#333;
+ border-bottom:1px solid #e5e5e5
+}
+.pure-form-stacked input[type=color],
+.pure-form-stacked input[type=date],
+.pure-form-stacked input[type=datetime-local],
+.pure-form-stacked input[type=datetime],
+.pure-form-stacked input[type=email],
+.pure-form-stacked input[type=file],
+.pure-form-stacked input[type=month],
+.pure-form-stacked input[type=number],
+.pure-form-stacked input[type=password],
+.pure-form-stacked input[type=search],
+.pure-form-stacked input[type=tel],
+.pure-form-stacked input[type=text],
+.pure-form-stacked input[type=time],
+.pure-form-stacked input[type=url],
+.pure-form-stacked input[type=week],
+.pure-form-stacked label,
+.pure-form-stacked select,
+.pure-form-stacked textarea {
+ display:block;
+ margin:.25em 0
+}
+.pure-form-stacked input:not([type]) {
+ display:block;
+ margin:.25em 0
+}
+.pure-form-aligned input,
+.pure-form-aligned select,
+.pure-form-aligned textarea,
+.pure-form-message-inline {
+ display:inline-block;
+ vertical-align:middle
+}
+.pure-form-aligned textarea {
+ vertical-align:top
+}
+.pure-form-aligned .pure-control-group {
+ margin-bottom:.5em
+}
+.pure-form-aligned .pure-control-group label {
+ text-align:right;
+ display:inline-block;
+ vertical-align:middle;
+ width:10em;
+ margin:0 1em 0 0
+}
+.pure-form-aligned .pure-controls {
+ margin:1.5em 0 0 11em
+}
+.pure-form .pure-input-rounded,
+.pure-form input.pure-input-rounded {
+ border-radius:2em;
+ padding:.5em 1em
+}
+.pure-form .pure-group fieldset {
+ margin-bottom:10px
+}
+.pure-form .pure-group input,
+.pure-form .pure-group textarea {
+ display:block;
+ padding:10px;
+ margin:0 0 -1px;
+ border-radius:0;
+ position:relative;
+ top:-1px
+}
+.pure-form .pure-group input:focus,
+.pure-form .pure-group textarea:focus {
+ z-index:3
+}
+.pure-form .pure-group input:first-child,
+.pure-form .pure-group textarea:first-child {
+ top:1px;
+ border-radius:4px 4px 0 0;
+ margin:0
+}
+.pure-form .pure-group input:first-child:last-child,
+.pure-form .pure-group textarea:first-child:last-child {
+ top:1px;
+ border-radius:4px;
+ margin:0
+}
+.pure-form .pure-group input:last-child,
+.pure-form .pure-group textarea:last-child {
+ top:-2px;
+ border-radius:0 0 4px 4px;
+ margin:0
+}
+.pure-form .pure-group button {
+ margin:.35em 0
+}
+.pure-form .pure-input-1 {
+ width:100%
+}
+.pure-form .pure-input-3-4 {
+ width:75%
+}
+.pure-form .pure-input-2-3 {
+ width:66%
+}
+.pure-form .pure-input-1-2 {
+ width:50%
+}
+.pure-form .pure-input-1-3 {
+ width:33%
+}
+.pure-form .pure-input-1-4 {
+ width:25%
+}
+.pure-form-message-inline {
+ display:inline-block;
+ padding-left:.3em;
+ color:#666;
+ vertical-align:middle;
+ font-size:.875em
+}
+.pure-form-message {
+ display:block;
+ color:#666;
+ font-size:.875em
+}
+@media only screen and (max-width :480px) {
+ .pure-form button[type=submit] {
+  margin:.7em 0 0
+ }
+ .pure-form input:not([type]),
+ .pure-form input[type=color],
+ .pure-form input[type=date],
+ .pure-form input[type=datetime-local],
+ .pure-form input[type=datetime],
+ .pure-form input[type=email],
+ .pure-form input[type=month],
+ .pure-form input[type=number],
+ .pure-form input[type=password],
+ .pure-form input[type=search],
+ .pure-form input[type=tel],
+ .pure-form input[type=text],
+ .pure-form input[type=time],
+ .pure-form input[type=url],
+ .pure-form input[type=week],
+ .pure-form label {
+  margin-bottom:.3em;
+  display:block
+ }
+ .pure-group input:not([type]),
+ .pure-group input[type=color],
+ .pure-group input[type=date],
+ .pure-group input[type=datetime-local],
+ .pure-group input[type=datetime],
+ .pure-group input[type=email],
+ .pure-group input[type=month],
+ .pure-group input[type=number],
+ .pure-group input[type=password],
+ .pure-group input[type=search],
+ .pure-group input[type=tel],
+ .pure-group input[type=text],
+ .pure-group input[type=time],
+ .pure-group input[type=url],
+ .pure-group input[type=week] {
+  margin-bottom:0
+ }
+ .pure-form-aligned .pure-control-group label {
+  margin-bottom:.3em;
+  text-align:left;
+  display:block;
+  width:100%
+ }
+ .pure-form-aligned .pure-controls {
+  margin:1.5em 0 0 0
+ }
+ .pure-form-message,
+ .pure-form-message-inline {
+  display:block;
+  font-size:.75em;
+  padding:.2em 0 .8em
+ }
+}
+.pure-menu {
+ box-sizing:border-box
+}
+.pure-menu-fixed {
+ position:fixed;
+ left:0;
+ top:0;
+ z-index:3
+}
+.pure-menu-item,
+.pure-menu-list {
+ position:relative
+}
+.pure-menu-list {
+ list-style:none;
+ margin:0;
+ padding:0
+}
+.pure-menu-item {
+ padding:0;
+ margin:0;
+ height:100%
+}
+.pure-menu-heading,
+.pure-menu-link {
+ display:block;
+ text-decoration:none;
+ white-space:nowrap
+}
+.pure-menu-horizontal {
+ width:100%;
+ white-space:nowrap
+}
+.pure-menu-horizontal .pure-menu-list {
+ display:inline-block
+}
+.pure-menu-horizontal .pure-menu-heading,
+.pure-menu-horizontal .pure-menu-item,
+.pure-menu-horizontal .pure-menu-separator {
+ display:inline-block;
+ vertical-align:middle
+}
+.pure-menu-item .pure-menu-item {
+ display:block
+}
+.pure-menu-children {
+ display:none;
+ position:absolute;
+ left:100%;
+ top:0;
+ margin:0;
+ padding:0;
+ z-index:3
+}
+.pure-menu-horizontal .pure-menu-children {
+ left:0;
+ top:auto;
+ width:inherit
+}
+.pure-menu-active>.pure-menu-children,
+.pure-menu-allow-hover:hover>.pure-menu-children {
+ display:block;
+ position:absolute
+}
+.pure-menu-has-children>.pure-menu-link:after {
+ padding-left:.5em;
+ content:"\25B8";
+ font-size:small
+}
+.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after {
+ content:"\25BE"
+}
+.pure-menu-scrollable {
+ overflow-y:scroll;
+ overflow-x:hidden
+}
+.pure-menu-scrollable .pure-menu-list {
+ display:block
+}
+.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list {
+ display:inline-block
+}
+.pure-menu-horizontal.pure-menu-scrollable {
+ white-space:nowrap;
+ overflow-y:hidden;
+ overflow-x:auto;
+ padding:.5em 0
+}
+.pure-menu-horizontal .pure-menu-children .pure-menu-separator,
+.pure-menu-separator {
+ background-color:#ccc;
+ height:1px;
+ margin:.3em 0
+}
+.pure-menu-horizontal .pure-menu-separator {
+ width:1px;
+ height:1.3em;
+ margin:0 .3em
+}
+.pure-menu-horizontal .pure-menu-children .pure-menu-separator {
+ display:block;
+ width:auto
+}
+.pure-menu-heading {
+ text-transform:uppercase;
+ color:#565d64
+}
+.pure-menu-link {
+ color:#777
+}
+.pure-menu-children {
+ background-color:#fff
+}
+.pure-menu-heading,
+.pure-menu-link {
+ padding:.5em 1em
+}
+.pure-menu-disabled {
+ opacity:.5
+}
+.pure-menu-disabled .pure-menu-link:hover {
+ background-color:transparent;
+ cursor:default
+}
+.pure-menu-active>.pure-menu-link,
+.pure-menu-link:focus,
+.pure-menu-link:hover {
+ background-color:#eee
+}
+.pure-menu-selected>.pure-menu-link,
+.pure-menu-selected>.pure-menu-link:visited {
+ color:#000
+}
+.pure-table {
+ border-collapse:collapse;
+ border-spacing:0;
+ empty-cells:show;
+ border:1px solid #cbcbcb
+}
+.pure-table caption {
+ color:#000;
+ font:italic 85%/1 arial,sans-serif;
+ padding:1em 0;
+ text-align:center
+}
+.pure-table td,
+.pure-table th {
+ border-left:1px solid #cbcbcb;
+ border-width:0 0 0 1px;
+ font-size:inherit;
+ margin:0;
+ overflow:visible;
+ padding:.5em 1em
+}
+.pure-table thead {
+ background-color:#e0e0e0;
+ color:#000;
+ text-align:left;
+ vertical-align:bottom
+}
+.pure-table td {
+ background-color:transparent
+}
+.pure-table-odd td {
+ background-color:#f2f2f2
+}
+.pure-table-striped tr:nth-child(2n-1) td {
+ background-color:#f2f2f2
+}
+.pure-table-bordered td {
+ border-bottom:1px solid #cbcbcb
+}
+.pure-table-bordered tbody>tr:last-child>td {
+ border-bottom-width:0
+}
+.pure-table-horizontal td,
+.pure-table-horizontal th {
+ border-width:0 0 1px 0;
+ border-bottom:1px solid #cbcbcb
+}
+.pure-table-horizontal tbody>tr:last-child>td {
+ border-bottom-width:0
+}
+
+/* custom styles */
+
+:root {
+ --slide: 25em;
+ --blue: #129fea;
+ --darkgray: #242424;
+ --lightgray: #cad2d3;
+ --darkergray: #191919;
+}
+
+body {
+ color:#aaa;
+ background: var(--darkgray);
+}
+
+.pure-img-responsive {
+ max-width:100%;
+ height:auto
+}
+
+#layout,
+#menu,
+.menu-link {
+ -webkit-transition:all 0.2s ease-out;
+ -moz-transition:all 0.2s ease-out;
+ -ms-transition:all 0.2s ease-out;
+ -o-transition:all 0.2s ease-out;
+ transition:all 0.2s ease-out
+}
+#layout {
+ position:relative;
+ left:0;
+ padding-left:0
+}
+#layout.active #menu {
+ left:var(--slide);
+ width:var(--slide)
+}
+#layout.active .menu-link {
+ left:var(--slide)
+}
+.content {
+ margin:0 auto;
+ padding:0 2em;
+ max-width: 100em;
+ margin-bottom: 5em;
+ line-height:1.6em
+}
+.header {
+ margin:0;
+ color: var(--darkgray);
+ text-align:center
+}
+.header h1 {
+ color: var(--blue);
+ margin:.4em 0;
+ padding-top: 0.5em;
+ font-size: 2em;
+ font-weight: bold;
+ font-variant-caps: small-caps
+}
+.header h2 {
+ font-weight:300;
+ color:#ccc;
+ padding:0;
+ margin-top:0
+}
+.content-subhead {
+ margin: 5em 0 2em 0;
+ font-weight:300;
+ color: var(--lightgray)
+}
+#menu {
+ margin-left:calc(var(--slide)* -1);
+ width:var(--slide);
+ height: 100vh;
+ position:fixed;
+ top:0;
+ left:0;
+ bottom:0;
+ z-index:1000;
+ background: var(--darkergray);
+ overflow-y:auto;
+ -webkit-overflow-scrolling:touch
+}
+#menu a {
+ color: var(--lightgray);
+ border:none;
+ padding:.6em 0.3em .6em 0.6em
+}
+#menu .pure-menu,
+#menu .pure-menu ul {
+ border:none;
+ background:transparent
+}
+#menu .pure-menu ul,
+#menu .pure-menu .menu-item-divided {
+ border-top: 0.1em solid var(--darkgray)
+}
+#menu .pure-menu li a:hover,
+#menu .pure-menu li a:focus {
+ background: var(--darkgray);
+ color: var(--lightgray);
+}
+#menu .pure-menu-selected,
+#menu .pure-menu-heading {
+ background: var(--blue);
+}
+#menu .pure-menu-selected a {
+ color: white
+}
+#menu .pure-menu-heading {
+ letter-spacing:.15em;
+ text-transform:uppercase;
+ color: white;
+ margin:0;
+ text-align:center;
+ padding:.6em 0.3em .6em 0.3em
+}
+.menu-link {
+ position:fixed;
+ display:block;
+ top:0;
+ left:0;
+ background: black;
+ background:rgba(0,0,0,0.42);
+ font-size: 1em;
+ z-index:10;
+ width:2em;
+ height:auto;
+ padding:2.1em 1.6em
+}
+.menu-link:hover,
+.menu-link:focus {
+ background: black;
+}
+.menu-link span {
+ position:relative;
+ display:block
+}
+.menu-link span,
+.menu-link span:before,
+.menu-link span:after {
+ background-color: var(--blue);
+ width:100%;
+ height:.2em
+}
+.menu-link span:before,
+.menu-link span:after {
+ position:absolute;
+ margin-top:-.6em;
+ content:" "
+}
+.menu-link span:after {
+ margin-top:.6em
+}
+@media(min-width:800px) {
+ .header,
+ .content {
+  padding-left:2em;
+  padding-right:2em
+ }
+ #layout {
+  padding-left:var(--slide);
+  left:0
+ }
+ #menu {
+  left:var(--slide)
+ }
+ .menu-link {
+  position:fixed;
+  left:var(--slide);
+  display:none
+ }
+ #layout.active .menu-link {
+  left:var(--slide)
+ }
+}
+
+/* Sliders */
+
+input[type=range] {
+ -webkit-appearance:none;
+ margin:0 0 0 0;
+ width:100%;
+ background: transparent;
+}
+
+input[type=range]:focus {
+ outline:none
+}
+
+input[type=range]::-webkit-slider-runnable-track {
+ width:100%;
+ height: 0.4em;
+ cursor:pointer;
+ animate:0.2s;
+ box-shadow: none
+ background: var(--blue);
+ border-radius: 0.3em;
+ border: none
+}
+
+input[type=range]::-webkit-slider-thumb {
+ box-shadow: none;
+ border: 0.1em solid var(--lightgray)
+ height: 2em;
+ width: 2em;
+ border-radius: 2em;
+ background: var(--lightgray)
+ cursor:pointer;
+ -webkit-appearance:none;
+ margin-top: 0.85em
+}
+
+input[type=range]:focus::-webkit-slider-runnable-track {
+ background: var(--blue)
+}
+
+input[type=range]::-moz-range-track {
+ width: 100%;
+ height: 0.4em;
+ cursor: pointer;
+ animate:0.2s;
+ box-shadow: none;
+ background: var(--blue);
+ border-radius: 0.3em;
+ border: none
+}
+
+input[type=range]::-moz-range-thumb {
+ box-shadow: none;
+ border: 0.1em solid var(--blue);
+ height: 2em;
+ width: 2em;
+ border-radius: 2em;
+ background: var(--lightgray);
+ cursor:pointer
+}
+
+input[type=range]::-ms-track {
+ width:100%;
+ height: 0.4em;
+ cursor:pointer;
+ animate:0.2s;
+ background:transparent;
+ border-color:transparent;
+ color:transparent
+}
+
+input[type=range]::-ms-fill-lower {
+ background: var(--lightgray);
+ border: none;
+ border-radius: 0.6em;
+ box-shadow: none
+}
+
+input[type=range]::-ms-fill-upper {
+ background: var(--lightgray);
+ border: none;
+ border-radius: 0.6em;
+ box-shadow: none
+}
+
+input[type=range]::-ms-thumb {
+ margin-top:0.1em;
+ box-shadow: nonel
+ border: 0.1em solid var(--blue);
+ height: 2em;
+ width: 2em;
+ border-radius: 2em;
+ background: var(--lightgray);
+ cursor:pointer
+}
+
+input[type=range]:focus::-ms-fill-lower {
+ background: var(--lightgray);
+}
+
+input[type=range]:focus::-ms-fill-upper {
+ background: var(--lightgray);
+}
+
+
+.hide {
+ display:none
+}
+
+/* footer */
+
+footer {
+ text-align:center;
+ position: absolute;
+ bottom: 1rem;
+ width: 100%
+}
+footer a {
+ display: inline-block;
+ text-decoration: none
+}
+
+/* notification button */
+
+.notification {
+  position: fixed;
+  right: 0.5em;
+  top: 0.5em;
+  background-color: var(--darkgray);
+  z-index:200;
+  word-wrap: anywhere;
+  float: right;
+ }
+
+/* notification message */
+
+.message {
+  position: fixed;
+  right: 1em;
+  bottom: 0.5em;
+  z-index:200;
+  float: right;
+  color:#fff;
+}
+
+.message-text {
+  background-color: var(--darkgray);
+  border:0.2em solid var(--blue);
+  box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+  word-wrap: anywhere;
+  font-weight: bolder;
+  font-size: 120%;
+  padding: 1em;
+  border-radius: 0.5em;
+  position: relative;
+}
+
+.close-button {
+    content: 'X';
+    position: absolute;
+    right: -1em;
+    top: -1em;
+    width: 1.2em;
+    padding: 0.1em 0.1em 0.1em 0.1em;
+    text-decoration: none;
+    text-shadow: none;
+    text-align: center;
+    font-weight: bold;
+    background: var(--darkgray);
+    color: white;
+    border: 0.2em solid var(--blue);
+    border-radius: 1em;
+    box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+}
+
+.fadeout {
+    animation: fadeOut 5s ease;
+    opacity:0;
+}
+
+@keyframes fadeOut {
+  0% {opacity:1;}
+  100% {opacity:0;}
+}
+
+/* modal dialog */ 
+.modal {
+  position: fixed; /* Stay in place */
+  z-index: 2000; /* Sit on top */
+  left: 0;
+  top: 0;
+  width: 100%; /* Full width */
+  height: 100%; /* Full height */
+  overflow: auto; /* Enable scroll if needed */
+  background-color: rgba(0,0,0,0.3); /* Black w/ opacity */
+}
+.modal-content {
+  -webkit-appearance:button;
+  cursor:pointer;
+  letter-spacing:.05em;
+  text-transform:uppercase;
+  color:var(--lightgray);
+  font-size:90%;
+  border:0.2em solid var(--blue);
+  border-radius:6px;
+  box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+  padding: 1em;
+  width: max-content;
+  min-width: 30%;
+  background-color: var(--darkgray);
+  margin: 10% auto;
+  padding: 1rem
+}
+.table-header {
+  font-weight: bold;
+  font-size: 120%;
+  text-align: center;
+  border-bottom: var(--blue) solid 0.1em;
+}
+
+/* overlapping hint button */
+
+.hinted {
+  position: relative;
+}
+
+.hint {
+  cursor: pointer;
+  width: 1rem;
+  background: transparent;
+  color: var(--darkgray);
+  padding: .5em .6em;
+  border: none;
+  box-shadow: none;
+  position: absolute;
+  top: 0em;
+  right: 0em;
+  line-height: 1.15em;
+  font-size: 100%;
+}
+
+/* Spacing */
+fieldset .pure-u-1 {
+  margin-top: 1.5em
+}
+
+/* checkbox as a switch */
+
+input[type=checkbox].switch {
+    display: none;
+}
+label.switch.socket {
+    position: relative;
+    width: auto;
+    padding-left: 4em;
+}
+span.switch.slider {
+  display: inline-block;
+}
+label.switch.socket span.switch.slider:before {
+    content: "";
+    color: gray;
+    width: 3em;
+    height: 1.4em;
+    border-radius: 1em;
+    position: absolute;
+    left: 0;
+    top: 0;
+    border:0.2em solid var(--blue);
+    box-shadow:inset 0 0.1em 1.1em 0 var(--blue)
+}
+label.switch span.switch.slider:after {
+    content: "";
+    width: 1rem;
+    height: 1rem;
+    background: var(--blue);
+    border-radius: 1em;
+    position: absolute;
+    left: 0.4rem;
+    top: 0.4rem;
+    transition: 0.2s;
+}
+label.switch.socket input[type=checkbox]:checked + span.slider:before {
+    background: var(--lightgray);
+}
+
+label.switch.socket input[type=checkbox]:checked + span.slider:after {
+    background: white;
+    left: 1.9em;
+}
+
+/* Time setter */
+
+.inline-input.inline-input.inline-input {
+  display: inline-block;
+  width: calc(100% - 5em)
+}
+
+.send-button {
+ display:inline-block;
+ font-family:inherit;
+ font-size:100%;
+ line-height: 1.15em;
+ padding: .5em 1em;
+ color:rgba(0,0,0,.8);
+ border:none transparent;
+ background-color:#e6e6e6;
+ text-decoration:none;
+ border-radius:2px;
+ vertical-align: middle;
+ margin-left: 1em;
+ width: 2em;
+ text-align: center;
+}
+
+/* weekdays */
+
+.week {
+    width: 100%;
+    border-collapse: collapse;
+    border-spacing: 0;
+}
+.week td {
+  padding: 0
+}
+.weekday, .weekday-selected {
+    font-size: min(4vw, 100%rem);
+    text-align: center;
+    margin: 0.2vw;
+    padding: 0.2em;
+    border-radius: 5em;
+    border:0.2em solid var(--blue);
+    box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+    color: gray
+}
+.weekday-selected {
+    background: var(--lightgray);
+    color: black
+}
+
+/* Hide arrows */
+
+input[type=number] {
+  appearance: textfield;
+}
+
+/* fill all screen */
+
+#layout { 
+  min-height: 100vh; 
+}
+
+/* Text table */
+
+.texttable {
+  border-collapse: collapse;
+  border-spacing: 0;
+  width: 100%;
+}
+
+.texttable td {
+  padding: 0
+}
+
+.value-name.value-name {
+  width: 50%;
+  padding-right: 1em;
+}
+
+/* Config */
+
+input[type=file]::file-selector-button {
+    display: none;
+}
+
+input[type=file]::-webkit-file-upload-button {
+    display: block;
+    width: 0;
+    height: 0;
+    margin-left: -100%;
+}
+
+input[type=file]::-ms-browse {
+    display: none;
+}
+
+.row-button.row-button.row-button {
+  display: inline-block;
+  margin: 0 2em 0 2em
+}
+
+/* fonts */
+
+@font-face {
+  font-family: "icons";
+  font-style: normal;
+  font-weight: 400;
+  src: url("/icons.woff2") format("woff2");
+}
+.hint, .icon {
+  font-family: icons;
+}
+.icon {
+  margin-right: 0.5em;
+}
diff --git a/description b/description
new file mode 100644 (file)
index 0000000..ad7f9b4
--- /dev/null
@@ -0,0 +1,2 @@
+description = "ESP8266 alarm clock"
+owner       = "rvb@rvb.name"
diff --git a/esp-clock.ino b/esp-clock.ino
new file mode 100644 (file)
index 0000000..a031d47
--- /dev/null
@@ -0,0 +1,50 @@
+#include "Clock.h"
+#include <LittleFS.h>
+
+void setup() {
+  // put your setup code here, to run once:
+
+  Serial.begin(115200);
+  Serial.println();
+  Serial.println(F("Starting..."));
+
+  setupConfig();
+  Serial.println(cfg);
+
+  setupHandlers();
+
+  setupHardware();
+  setupPanel();
+
+  setupNet();
+  setupTime();
+
+  setupAlarm();
+  setupWeatherRequest();
+
+  setupWeb();
+}
+
+void mem() {
+  Serial.println(F("-------------------------------------------------------------"));
+  Serial.print("Heap:"); Serial.print(ESP.getFreeHeap()); 
+  Serial.print(" Largest chunk:"); Serial.print(ESP.getMaxFreeBlockSize());
+  Serial.print(" Fragmentation:"); Serial.print(ESP.getHeapFragmentation());
+  Serial.print(" Stack:"); Serial.println(ESP.getFreeContStack());
+  Serial.println(F("-------------------------------------------------------------"));
+}
+
+void loop() {
+  static unsigned long lastMillis = 0;
+  int interval = 5000;
+  // put your main code here, to run repeatedly:
+  if (millis() - lastMillis > interval) {
+    lastMillis = millis();
+    mem();
+  }
+  tickNet();
+  tickTime();
+  tickHardware();
+  tickPanel();
+  tickWeb();
+}
diff --git a/fonts.h b/fonts.h
new file mode 100644 (file)
index 0000000..585b428
--- /dev/null
+++ b/fonts.h
@@ -0,0 +1,1828 @@
+#pragma once
+
+MD_MAX72XX::fontType_t NumbersSinclair[] PROGMEM = 
+{
+       0,      // 0
+       0,      // 1
+       0,      // 2
+       0,      // 3
+       0,      // 4
+       0,      // 5
+       0,      // 6
+       0,      // 7
+       0,      // 8
+       0,      // 9
+       0,      // 10
+       0,      // 11
+       0,      // 12
+       0,      // 13
+       0,      // 14
+       0,      // 15
+       0,      // 16
+       0,      // 17
+       0,      // 18
+       0,      // 19
+       0,      // 20
+       0,      // 21
+       0,      // 22
+       0,      // 23
+       0,      // 24
+       0,      // 25
+       0,      // 26
+       0,      // 27
+       0,      // 28
+       0,      // 29
+       0,      // 30
+       0,      // 31
+       2, 0, 0,        // 32
+       0,      // 33
+       0,      // 34
+       0,      // 35
+       0,      // 36
+       0,      // 37
+       0,      // 38
+       0,      // 39
+       0,      // 40
+       0,      // 41
+       0,      // 42
+       0,      // 43
+       0,      // 44
+       3, 24, 24, 24,  // 45
+       2, 192, 192,    // 46
+       0,      // 47
+       7, 255, 255, 145, 137, 255, 255, 255,   // 48
+       7, 0, 130, 255, 255, 255, 128, 0,       // 49
+       7, 243, 243, 145, 145, 159, 223, 223,   // 50
+       7, 129, 129, 145, 145, 255, 255, 255,   // 51
+       7, 31, 31, 16, 16, 255, 255, 255,       // 52
+       7, 223, 223, 145, 145, 241, 243, 243,   // 53
+       7, 255, 255, 137, 137, 249, 249, 249,   // 54
+       7, 1, 1, 1, 1, 255, 255, 255,   // 55
+       7, 247, 255, 137, 137, 255, 255, 247,   // 56
+       7, 223, 223, 145, 145, 255, 255, 255,   // 57
+       2, 102, 102,    // 58
+       0,      // 59
+       0,      // 60
+       0,      // 61
+       0,      // 62
+       0,      // 63
+       0,      // 64
+       0,      // 65
+       0,      // 66
+       0,      // 67
+       0,      // 68
+       0,      // 69
+       0,      // 70
+       0,      // 71
+       0,      // 72
+       0,      // 73
+       0,      // 74
+       0,      // 75
+       0,      // 76
+       0,      // 77
+       0,      // 78
+       0,      // 79
+       0,      // 80
+       0,      // 81
+       0,      // 82
+       0,      // 83
+       0,      // 84
+       0,      // 85
+       0,      // 86
+       0,      // 87
+       0,      // 88
+       0,      // 89
+       0,      // 90
+       0,      // 91
+       0,      // 92
+       0,      // 93
+       0,      // 94
+       0,      // 95
+       0,      // 96
+       0,      // 97
+       0,      // 98
+       0,      // 99
+       0,      // 100
+       0,      // 101
+       0,      // 102
+       0,      // 103
+       0,      // 104
+       0,      // 105
+       0,      // 106
+       0,      // 107
+       0,      // 108
+       0,      // 109
+       0,      // 110
+       0,      // 111
+       0,      // 112
+       0,      // 113
+       0,      // 114
+       0,      // 115
+       0,      // 116
+       0,      // 117
+       0,      // 118
+       0,      // 119
+       0,      // 120
+       0,      // 121
+       0,      // 122
+       0,      // 123
+       0,      // 124
+       0,      // 125
+       0,      // 126
+       0,      // 127
+       0,      // 128
+       0,      // 129
+       0,      // 130
+       0,      // 131
+       0,      // 132
+       0,      // 133
+       0,      // 134
+       0,      // 135
+       0,      // 136
+       0,      // 137
+       0,      // 138
+       0,      // 139
+       0,      // 140
+       0,      // 141
+       0,      // 142
+       0,      // 143
+       0,      // 144
+       0,      // 145
+       0,      // 146
+       0,      // 147
+       0,      // 148
+       0,      // 149
+       0,      // 150
+       0,      // 151
+       0,      // 152
+       0,      // 153
+       0,      // 154
+       0,      // 155
+       0,      // 156
+       0,      // 157
+       0,      // 158
+       0,      // 159
+       0,      // 160
+       0,      // 161
+       0,      // 162
+       0,      // 163
+       0,      // 164
+       0,      // 165
+       0,      // 166
+       0,      // 167
+       0,      // 168
+       0,      // 169
+       0,      // 170
+       0,      // 171
+       0,      // 172
+       0,      // 173
+       0,      // 174
+       0,      // 175
+       0,      // 176
+       0,      // 177
+       0,      // 178
+       0,      // 179
+       0,      // 180
+       0,      // 181
+       0,      // 182
+       0,      // 183
+       0,      // 184
+       0,      // 185
+       0,      // 186
+       0,      // 187
+       0,      // 188
+       0,      // 189
+       0,      // 190
+       0,      // 191
+       0,      // 192
+       0,      // 193
+       0,      // 194
+       0,      // 195
+       0,      // 196
+       0,      // 197
+       0,      // 198
+       0,      // 199
+       0,      // 200
+       0,      // 201
+       0,      // 202
+       0,      // 203
+       0,      // 204
+       0,      // 205
+       0,      // 206
+       0,      // 207
+       0,      // 208
+       0,      // 209
+       0,      // 210
+       0,      // 211
+       0,      // 212
+       0,      // 213
+       0,      // 214
+       0,      // 215
+       0,      // 216
+       0,      // 217
+       0,      // 218
+       0,      // 219
+       0,      // 220
+       0,      // 221
+       0,      // 222
+       0,      // 223
+       0,      // 224
+       0,      // 225
+       0,      // 226
+       0,      // 227
+       0,      // 228
+       0,      // 229
+       0,      // 230
+       0,      // 231
+       0,      // 232
+       0,      // 233
+       0,      // 234
+       0,      // 235
+       0,      // 236
+       0,      // 237
+       0,      // 238
+       0,      // 239
+       0,      // 240
+       0,      // 241
+       0,      // 242
+       0,      // 243
+       0,      // 244
+       0,      // 245
+       0,      // 246
+       0,      // 247
+       0,      // 248
+       0,      // 249
+       0,      // 250
+       0,      // 251
+       0,      // 252
+       0,      // 253
+       0,      // 254
+       0,      // 255
+};
+
+MD_MAX72XX::fontType_t NumbersBoldStraight[] PROGMEM = 
+{
+       0,      // 0
+       0,      // 1
+       0,      // 2
+       0,      // 3
+       0,      // 4
+       0,      // 5
+       0,      // 6
+       0,      // 7
+       0,      // 8
+       0,      // 9
+       0,      // 10
+       0,      // 11
+       0,      // 12
+       0,      // 13
+       0,      // 14
+       0,      // 15
+       0,      // 16
+       0,      // 17
+       0,      // 18
+       0,      // 19
+       0,      // 20
+       0,      // 21
+       0,      // 22
+       0,      // 23
+       0,      // 24
+       0,      // 25
+       0,      // 26
+       0,      // 27
+       0,      // 28
+       0,      // 29
+       0,      // 30
+       0,      // 31
+       2, 0, 0,        // 32
+       0,      // 33
+       0,      // 34
+       0,      // 35
+       0,      // 36
+       0,      // 37
+       0,      // 38
+       0,      // 39
+       0,      // 40
+       0,      // 41
+       0,      // 42
+       0,      // 43
+       0,      // 44
+       3, 24, 24, 24,  // 45
+       2, 192, 192,    // 46
+       0,      // 47
+       6, 255, 255, 129, 129, 255, 255,        // 48
+       6, 0, 0, 255, 255, 0, 0,        // 49
+       6, 241, 241, 145, 145, 159, 159,        // 50
+       6, 129, 129, 137, 137, 255, 255,        // 51
+       6, 31, 31, 16, 16, 255, 255,    // 52
+       6, 143, 143, 137, 137, 249, 249,        // 53
+       6, 255, 255, 137, 137, 249, 249,        // 54
+       6, 1, 1, 1, 1, 255, 255,        // 55
+       6, 255, 255, 137, 137, 255, 255,        // 56
+       6, 223, 159, 145, 145, 255, 255,        // 57
+       2, 102, 102,    // 58
+       0,      // 59
+       0,      // 60
+       0,      // 61
+       0,      // 62
+       0,      // 63
+       0,      // 64
+       0,      // 65
+       0,      // 66
+       0,      // 67
+       0,      // 68
+       0,      // 69
+       0,      // 70
+       0,      // 71
+       0,      // 72
+       0,      // 73
+       0,      // 74
+       0,      // 75
+       0,      // 76
+       0,      // 77
+       0,      // 78
+       0,      // 79
+       0,      // 80
+       0,      // 81
+       0,      // 82
+       0,      // 83
+       0,      // 84
+       0,      // 85
+       0,      // 86
+       0,      // 87
+       0,      // 88
+       0,      // 89
+       0,      // 90
+       0,      // 91
+       0,      // 92
+       0,      // 93
+       0,      // 94
+       0,      // 95
+       0,      // 96
+       0,      // 97
+       0,      // 98
+       0,      // 99
+       0,      // 100
+       0,      // 101
+       0,      // 102
+       0,      // 103
+       0,      // 104
+       0,      // 105
+       0,      // 106
+       0,      // 107
+       0,      // 108
+       0,      // 109
+       0,      // 110
+       0,      // 111
+       0,      // 112
+       0,      // 113
+       0,      // 114
+       0,      // 115
+       0,      // 116
+       0,      // 117
+       0,      // 118
+       0,      // 119
+       0,      // 120
+       0,      // 121
+       0,      // 122
+       0,      // 123
+       0,      // 124
+       0,      // 125
+       0,      // 126
+       0,      // 127
+       0,      // 128
+       0,      // 129
+       0,      // 130
+       0,      // 131
+       0,      // 132
+       0,      // 133
+       0,      // 134
+       0,      // 135
+       0,      // 136
+       0,      // 137
+       0,      // 138
+       0,      // 139
+       0,      // 140
+       0,      // 141
+       0,      // 142
+       0,      // 143
+       0,      // 144
+       0,      // 145
+       0,      // 146
+       0,      // 147
+       0,      // 148
+       0,      // 149
+       0,      // 150
+       0,      // 151
+       0,      // 152
+       0,      // 153
+       0,      // 154
+       0,      // 155
+       0,      // 156
+       0,      // 157
+       0,      // 158
+       0,      // 159
+       0,      // 160
+       0,      // 161
+       0,      // 162
+       0,      // 163
+       0,      // 164
+       0,      // 165
+       0,      // 166
+       0,      // 167
+       0,      // 168
+       0,      // 169
+       0,      // 170
+       0,      // 171
+       0,      // 172
+       0,      // 173
+       0,      // 174
+       0,      // 175
+       0,      // 176
+       0,      // 177
+       0,      // 178
+       0,      // 179
+       0,      // 180
+       0,      // 181
+       0,      // 182
+       0,      // 183
+       0,      // 184
+       0,      // 185
+       0,      // 186
+       0,      // 187
+       0,      // 188
+       0,      // 189
+       0,      // 190
+       0,      // 191
+       0,      // 192
+       0,      // 193
+       0,      // 194
+       0,      // 195
+       0,      // 196
+       0,      // 197
+       0,      // 198
+       0,      // 199
+       0,      // 200
+       0,      // 201
+       0,      // 202
+       0,      // 203
+       0,      // 204
+       0,      // 205
+       0,      // 206
+       0,      // 207
+       0,      // 208
+       0,      // 209
+       0,      // 210
+       0,      // 211
+       0,      // 212
+       0,      // 213
+       0,      // 214
+       0,      // 215
+       0,      // 216
+       0,      // 217
+       0,      // 218
+       0,      // 219
+       0,      // 220
+       0,      // 221
+       0,      // 222
+       0,      // 223
+       0,      // 224
+       0,      // 225
+       0,      // 226
+       0,      // 227
+       0,      // 228
+       0,      // 229
+       0,      // 230
+       0,      // 231
+       0,      // 232
+       0,      // 233
+       0,      // 234
+       0,      // 235
+       0,      // 236
+       0,      // 237
+       0,      // 238
+       0,      // 239
+       0,      // 240
+       0,      // 241
+       0,      // 242
+       0,      // 243
+       0,      // 244
+       0,      // 245
+       0,      // 246
+       0,      // 247
+       0,      // 248
+       0,      // 249
+       0,      // 250
+       0,      // 251
+       0,      // 252
+       0,      // 253
+       0,      // 254
+       0,      // 255
+};
+
+MD_MAX72XX::fontType_t NumbersBold[] PROGMEM = 
+{
+       0,      // 0
+       0,      // 1
+       0,      // 2
+       0,      // 3
+       0,      // 4
+       0,      // 5
+       0,      // 6
+       0,      // 7
+       0,      // 8
+       0,      // 9
+       0,      // 10
+       0,      // 11
+       0,      // 12
+       0,      // 13
+       0,      // 14
+       0,      // 15
+       0,      // 16
+       0,      // 17
+       0,      // 18
+       0,      // 19
+       0,      // 20
+       0,      // 21
+       0,      // 22
+       0,      // 23
+       0,      // 24
+       0,      // 25
+       0,      // 26
+       0,      // 27
+       0,      // 28
+       0,      // 29
+       0,      // 30
+       0,      // 31
+       2, 0, 0,        // 32
+       0,      // 33
+       0,      // 34
+       0,      // 35
+       0,      // 36
+       0,      // 37
+       0,      // 38
+       0,      // 39
+       0,      // 40
+       0,      // 41
+       0,      // 42
+       0,      // 43
+       0,      // 44
+       3, 24, 24, 24,  // 45
+       2, 192, 192,    // 46
+       0,      // 47
+       6, 126, 255, 129, 129, 255, 126,        // 48
+       6, 132, 130, 255, 255, 128, 128,        // 49
+       6, 194, 227, 177, 153, 143, 134,        // 50
+       6, 66, 195, 137, 137, 255, 118,         // 51
+       6, 30, 31, 16, 16, 254, 254,    // 52
+       6, 79, 207, 137, 137, 249, 113,         // 53
+       6, 126, 251, 137, 137, 251, 114,        // 54
+       6, 1, 1, 241, 249, 15, 7,       // 55
+       6, 118, 255, 137, 137, 255, 118,        // 56
+       6, 78, 159, 145, 145, 255, 126,         // 57
+       2, 102, 102,    // 58
+       0,      // 59
+       0,      // 60
+       0,      // 61
+       0,      // 62
+       0,      // 63
+       0,      // 64
+       0,      // 65
+       0,      // 66
+       0,      // 67
+       0,      // 68
+       0,      // 69
+       0,      // 70
+       0,      // 71
+       0,      // 72
+       0,      // 73
+       0,      // 74
+       0,      // 75
+       0,      // 76
+       0,      // 77
+       0,      // 78
+       0,      // 79
+       0,      // 80
+       0,      // 81
+       0,      // 82
+       0,      // 83
+       0,      // 84
+       0,      // 85
+       0,      // 86
+       0,      // 87
+       0,      // 88
+       0,      // 89
+       0,      // 90
+       0,      // 91
+       0,      // 92
+       0,      // 93
+       0,      // 94
+       0,      // 95
+       0,      // 96
+       0,      // 97
+       0,      // 98
+       0,      // 99
+       0,      // 100
+       0,      // 101
+       0,      // 102
+       0,      // 103
+       0,      // 104
+       0,      // 105
+       0,      // 106
+       0,      // 107
+       0,      // 108
+       0,      // 109
+       0,      // 110
+       0,      // 111
+       0,      // 112
+       0,      // 113
+       0,      // 114
+       0,      // 115
+       0,      // 116
+       0,      // 117
+       0,      // 118
+       0,      // 119
+       0,      // 120
+       0,      // 121
+       0,      // 122
+       0,      // 123
+       0,      // 124
+       0,      // 125
+       0,      // 126
+       0,      // 127
+       0,      // 128
+       0,      // 129
+       0,      // 130
+       0,      // 131
+       0,      // 132
+       0,      // 133
+       0,      // 134
+       0,      // 135
+       0,      // 136
+       0,      // 137
+       0,      // 138
+       0,      // 139
+       0,      // 140
+       0,      // 141
+       0,      // 142
+       0,      // 143
+       0,      // 144
+       0,      // 145
+       0,      // 146
+       0,      // 147
+       0,      // 148
+       0,      // 149
+       0,      // 150
+       0,      // 151
+       0,      // 152
+       0,      // 153
+       0,      // 154
+       0,      // 155
+       0,      // 156
+       0,      // 157
+       0,      // 158
+       0,      // 159
+       0,      // 160
+       0,      // 161
+       0,      // 162
+       0,      // 163
+       0,      // 164
+       0,      // 165
+       0,      // 166
+       0,      // 167
+       0,      // 168
+       0,      // 169
+       0,      // 170
+       0,      // 171
+       0,      // 172
+       0,      // 173
+       0,      // 174
+       0,      // 175
+       0,      // 176
+       0,      // 177
+       0,      // 178
+       0,      // 179
+       0,      // 180
+       0,      // 181
+       0,      // 182
+       0,      // 183
+       0,      // 184
+       0,      // 185
+       0,      // 186
+       0,      // 187
+       0,      // 188
+       0,      // 189
+       0,      // 190
+       0,      // 191
+       0,      // 192
+       0,      // 193
+       0,      // 194
+       0,      // 195
+       0,      // 196
+       0,      // 197
+       0,      // 198
+       0,      // 199
+       0,      // 200
+       0,      // 201
+       0,      // 202
+       0,      // 203
+       0,      // 204
+       0,      // 205
+       0,      // 206
+       0,      // 207
+       0,      // 208
+       0,      // 209
+       0,      // 210
+       0,      // 211
+       0,      // 212
+       0,      // 213
+       0,      // 214
+       0,      // 215
+       0,      // 216
+       0,      // 217
+       0,      // 218
+       0,      // 219
+       0,      // 220
+       0,      // 221
+       0,      // 222
+       0,      // 223
+       0,      // 224
+       0,      // 225
+       0,      // 226
+       0,      // 227
+       0,      // 228
+       0,      // 229
+       0,      // 230
+       0,      // 231
+       0,      // 232
+       0,      // 233
+       0,      // 234
+       0,      // 235
+       0,      // 236
+       0,      // 237
+       0,      // 238
+       0,      // 239
+       0,      // 240
+       0,      // 241
+       0,      // 242
+       0,      // 243
+       0,      // 244
+       0,      // 245
+       0,      // 246
+       0,      // 247
+       0,      // 248
+       0,      // 249
+       0,      // 250
+       0,      // 251
+       0,      // 252
+       0,      // 253
+       0,      // 254
+       0,      // 255
+};
+
+MD_MAX72XX::fontType_t NumbersThin[] PROGMEM = 
+{
+       0,      // 0
+       0,      // 1
+       0,      // 2
+       0,      // 3
+       0,      // 4
+       0,      // 5
+       0,      // 6
+       0,      // 7
+       0,      // 8
+       0,      // 9
+       0,      // 10
+       0,      // 11
+       0,      // 12
+       0,      // 13
+       0,      // 14
+       0,      // 15
+       0,      // 16
+       0,      // 17
+       0,      // 18
+       0,      // 19
+       0,      // 20
+       0,      // 21
+       0,      // 22
+       0,      // 23
+       0,      // 24
+       0,      // 25
+       0,      // 26
+       0,      // 27
+       0,      // 28
+       0,      // 29
+       0,      // 30
+       0,      // 31
+       1, 0,   // 32
+       0,      // 33
+       0,      // 34
+       0,      // 35
+       0,      // 36
+       0,      // 37
+       0,      // 38
+       0,      // 39
+       0,      // 40
+       0,      // 41
+       0,      // 42
+       0,      // 43
+       0,      // 44
+       2, 16, 16,      // 45
+       1, 128,         // 46
+       0,      // 47
+       3, 255, 129, 255,       // 48
+       3, 0, 255, 0,   // 49
+       3, 243, 145, 159,       // 50
+       3, 195, 137, 255,       // 51
+       3, 31, 16, 255,         // 52
+       3, 143, 137, 249,       // 53
+       3, 255, 137, 249,       // 54
+       3, 1, 1, 255,   // 55
+       3, 255, 137, 255,       // 56
+       3, 159, 145, 255,       // 57
+       1, 36,  // 58
+       0,      // 59
+       0,      // 60
+       0,      // 61
+       0,      // 62
+       0,      // 63
+       0,      // 64
+       0,      // 65
+       0,      // 66
+       0,      // 67
+       0,      // 68
+       0,      // 69
+       0,      // 70
+       0,      // 71
+       0,      // 72
+       0,      // 73
+       0,      // 74
+       0,      // 75
+       0,      // 76
+       0,      // 77
+       0,      // 78
+       0,      // 79
+       0,      // 80
+       0,      // 81
+       0,      // 82
+       0,      // 83
+       0,      // 84
+       0,      // 85
+       0,      // 86
+       0,      // 87
+       0,      // 88
+       0,      // 89
+       0,      // 90
+       0,      // 91
+       0,      // 92
+       0,      // 93
+       0,      // 94
+       0,      // 95
+       0,      // 96
+       0,      // 97
+       0,      // 98
+       0,      // 99
+       0,      // 100
+       0,      // 101
+       0,      // 102
+       0,      // 103
+       0,      // 104
+       0,      // 105
+       0,      // 106
+       0,      // 107
+       0,      // 108
+       0,      // 109
+       0,      // 110
+       0,      // 111
+       0,      // 112
+       0,      // 113
+       0,      // 114
+       0,      // 115
+       0,      // 116
+       0,      // 117
+       0,      // 118
+       0,      // 119
+       0,      // 120
+       0,      // 121
+       0,      // 122
+       0,      // 123
+       0,      // 124
+       0,      // 125
+       0,      // 126
+       0,      // 127
+       0,      // 128
+       0,      // 129
+       0,      // 130
+       0,      // 131
+       0,      // 132
+       0,      // 133
+       0,      // 134
+       0,      // 135
+       0,      // 136
+       0,      // 137
+       0,      // 138
+       0,      // 139
+       0,      // 140
+       0,      // 141
+       0,      // 142
+       0,      // 143
+       0,      // 144
+       0,      // 145
+       0,      // 146
+       0,      // 147
+       0,      // 148
+       0,      // 149
+       0,      // 150
+       0,      // 151
+       0,      // 152
+       0,      // 153
+       0,      // 154
+       0,      // 155
+       0,      // 156
+       0,      // 157
+       0,      // 158
+       0,      // 159
+       0,      // 160
+       0,      // 161
+       0,      // 162
+       0,      // 163
+       0,      // 164
+       0,      // 165
+       0,      // 166
+       0,      // 167
+       0,      // 168
+       0,      // 169
+       0,      // 170
+       0,      // 171
+       0,      // 172
+       0,      // 173
+       0,      // 174
+       0,      // 175
+       0,      // 176
+       0,      // 177
+       0,      // 178
+       0,      // 179
+       0,      // 180
+       0,      // 181
+       0,      // 182
+       0,      // 183
+       0,      // 184
+       0,      // 185
+       0,      // 186
+       0,      // 187
+       0,      // 188
+       0,      // 189
+       0,      // 190
+       0,      // 191
+       0,      // 192
+       0,      // 193
+       0,      // 194
+       0,      // 195
+       0,      // 196
+       0,      // 197
+       0,      // 198
+       0,      // 199
+       0,      // 200
+       0,      // 201
+       0,      // 202
+       0,      // 203
+       0,      // 204
+       0,      // 205
+       0,      // 206
+       0,      // 207
+       0,      // 208
+       0,      // 209
+       0,      // 210
+       0,      // 211
+       0,      // 212
+       0,      // 213
+       0,      // 214
+       0,      // 215
+       0,      // 216
+       0,      // 217
+       0,      // 218
+       0,      // 219
+       0,      // 220
+       0,      // 221
+       0,      // 222
+       0,      // 223
+       0,      // 224
+       0,      // 225
+       0,      // 226
+       0,      // 227
+       0,      // 228
+       0,      // 229
+       0,      // 230
+       0,      // 231
+       0,      // 232
+       0,      // 233
+       0,      // 234
+       0,      // 235
+       0,      // 236
+       0,      // 237
+       0,      // 238
+       0,      // 239
+       0,      // 240
+       0,      // 241
+       0,      // 242
+       0,      // 243
+       0,      // 244
+       0,      // 245
+       0,      // 246
+       0,      // 247
+       0,      // 248
+       0,      // 249
+       0,      // 250
+       0,      // 251
+       0,      // 252
+       0,      // 253
+       0,      // 254
+       0,      // 255
+};
+
+MD_MAX72XX::fontType_t NumbersStandard[] PROGMEM = {
+0, // 0
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  1, 0x00, // 32 - 'Space'
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  3, 0x80, 0x70, 0x30,  // 44 - ','
+  0, 
+  2, 0x60, 0x60,  // 46 - '.'
+  0, 
+  5, 62, 65, 65, 65, 62,  // 48 - '0'
+  3, 0x42, 0x7f, 0x40,  // 49 - '1'
+  5, 0x72, 0x49, 0x49, 0x49, 0x46,  // 50 - '2'
+  5, 0x21, 0x41, 0x49, 0x4d, 0x33,  // 51 - '3'
+  5, 0x18, 0x14, 0x12, 0x7f, 0x10,  // 52 - '4'
+  5, 0x27, 0x45, 0x45, 0x45, 0x39,  // 53 - '5'
+  5, 0x3c, 0x4a, 0x49, 0x49, 0x31,  // 54 - '6'
+  5, 0x41, 0x21, 0x11, 0x09, 0x07,  // 55 - '7'
+  5, 0x36, 0x49, 0x49, 0x49, 0x36,  // 56 - '8'
+  5, 0x46, 0x49, 0x49, 0x29, 0x1e,  // 57 - '9'
+  1, 0x14,  // 58 - ':'
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+  0, 
+ };
+
+MD_MAX72XX::fontType_t Numbers5x8[] PROGMEM = 
+{
+       0,      // 0
+       0,      // 1
+       0,      // 2
+       0,      // 3
+       0,      // 4
+       0,      // 5
+       0,      // 6
+       0,      // 7
+       0,      // 8
+       0,      // 9
+       0,      // 10
+       0,      // 11
+       0,      // 12
+       0,      // 13
+       0,      // 14
+       0,      // 15
+       0,      // 16
+       0,      // 17
+       0,      // 18
+       0,      // 19
+       0,      // 20
+       0,      // 21
+       0,      // 22
+       0,      // 23
+       0,      // 24
+       0,      // 25
+       0,      // 26
+       0,      // 27
+       0,      // 28
+       0,      // 29
+       0,      // 30
+       0,      // 31
+  1, 0x00, // 32 - 'Space'
+       0,      // 33
+       0,      // 34
+       0,      // 35
+       0,      // 36
+       0,      // 37
+       0,      // 38
+       0,      // 39
+       0,      // 40
+       0,      // 41
+       0,      // 42
+       0,      // 43
+       0,      // 44
+       3, 16, 16, 16,  // 45
+       1, 128,         // 46
+       0,      // 47
+       5, 126, 129, 129, 129, 126,     // 48
+       5, 132, 130, 255, 128, 128,     // 49
+       5, 130, 193, 161, 145, 142,     // 50
+       5, 66, 129, 137, 137, 118,      // 51
+       5, 48, 40, 36, 34, 255,         // 52
+       5, 79, 137, 137, 137, 113,      // 53
+       5, 126, 137, 137, 137, 114,     // 54
+       5, 1, 225, 17, 9, 7,    // 55
+       5, 118, 137, 137, 137, 118,     // 56
+       5, 78, 145, 145, 145, 126,      // 57
+       1, 36,  // 58
+       0,      // 59
+       0,      // 60
+       0,      // 61
+       0,      // 62
+       0,      // 63
+       0,      // 64
+       0,      // 65
+       0,      // 66
+       0,      // 67
+       0,      // 68
+       0,      // 69
+       0,      // 70
+       0,      // 71
+       0,      // 72
+       0,      // 73
+       0,      // 74
+       0,      // 75
+       0,      // 76
+       0,      // 77
+       0,      // 78
+       0,      // 79
+       0,      // 80
+       0,      // 81
+       0,      // 82
+       0,      // 83
+       0,      // 84
+       0,      // 85
+       0,      // 86
+       0,      // 87
+       0,      // 88
+       0,      // 89
+       0,      // 90
+       0,      // 91
+       0,      // 92
+       0,      // 93
+       0,      // 94
+       0,      // 95
+       0,      // 96
+       0,      // 97
+       0,      // 98
+       0,      // 99
+       0,      // 100
+       0,      // 101
+       0,      // 102
+       0,      // 103
+       0,      // 104
+       0,      // 105
+       0,      // 106
+       0,      // 107
+       0,      // 108
+       0,      // 109
+       0,      // 110
+       0,      // 111
+       0,      // 112
+       0,      // 113
+       0,      // 114
+       0,      // 115
+       0,      // 116
+       0,      // 117
+       0,      // 118
+       0,      // 119
+       0,      // 120
+       0,      // 121
+       0,      // 122
+       0,      // 123
+       0,      // 124
+       0,      // 125
+       0,      // 126
+       0,      // 127
+       0,      // 128
+       0,      // 129
+       0,      // 130
+       0,      // 131
+       0,      // 132
+       0,      // 133
+       0,      // 134
+       0,      // 135
+       0,      // 136
+       0,      // 137
+       0,      // 138
+       0,      // 139
+       0,      // 140
+       0,      // 141
+       0,      // 142
+       0,      // 143
+       0,      // 144
+       0,      // 145
+       0,      // 146
+       0,      // 147
+       0,      // 148
+       0,      // 149
+       0,      // 150
+       0,      // 151
+       0,      // 152
+       0,      // 153
+       0,      // 154
+       0,      // 155
+       0,      // 156
+       0,      // 157
+       0,      // 158
+       0,      // 159
+       0,      // 160
+       0,      // 161
+       0,      // 162
+       0,      // 163
+       0,      // 164
+       0,      // 165
+       0,      // 166
+       0,      // 167
+       0,      // 168
+       0,      // 169
+       0,      // 170
+       0,      // 171
+       0,      // 172
+       0,      // 173
+       0,      // 174
+       0,      // 175
+       0,      // 176
+       0,      // 177
+       0,      // 178
+       0,      // 179
+       0,      // 180
+       0,      // 181
+       0,      // 182
+       0,      // 183
+       0,      // 184
+       0,      // 185
+       0,      // 186
+       0,      // 187
+       0,      // 188
+       0,      // 189
+       0,      // 190
+       0,      // 191
+       0,      // 192
+       0,      // 193
+       0,      // 194
+       0,      // 195
+       0,      // 196
+       0,      // 197
+       0,      // 198
+       0,      // 199
+       0,      // 200
+       0,      // 201
+       0,      // 202
+       0,      // 203
+       0,      // 204
+       0,      // 205
+       0,      // 206
+       0,      // 207
+       0,      // 208
+       0,      // 209
+       0,      // 210
+       0,      // 211
+       0,      // 212
+       0,      // 213
+       0,      // 214
+       0,      // 215
+       0,      // 216
+       0,      // 217
+       0,      // 218
+       0,      // 219
+       0,      // 220
+       0,      // 221
+       0,      // 222
+       0,      // 223
+       0,      // 224
+       0,      // 225
+       0,      // 226
+       0,      // 227
+       0,      // 228
+       0,      // 229
+       0,      // 230
+       0,      // 231
+       0,      // 232
+       0,      // 233
+       0,      // 234
+       0,      // 235
+       0,      // 236
+       0,      // 237
+       0,      // 238
+       0,      // 239
+       0,      // 240
+       0,      // 241
+       0,      // 242
+       0,      // 243
+       0,      // 244
+       0,      // 245
+       0,      // 246
+       0,      // 247
+       0,      // 248
+       0,      // 249
+       0,      // 250
+       0,      // 251
+       0,      // 252
+       0,      // 253
+       0,      // 254
+       0,      // 255
+};
+
+MD_MAX72XX::fontType_t RomanCyrillic[] PROGMEM = {
+ 0, // 0
+  5, 0x3e, 0x5b, 0x4f, 0x5b, 0x3e,  // 1 - 'Sad Smiley'
+  5, 0x3e, 0x6b, 0x4f, 0x6b, 0x3e,  // 2 - 'Happy Smiley'
+  5, 0x1c, 0x3e, 0x7c, 0x3e, 0x1c,  // 3 - 'Heart'
+  5, 0x18, 0x3c, 0x7e, 0x3c, 0x18,  // 4 - 'Diamond'
+  5, 0x1c, 0x57, 0x7d, 0x57, 0x1c,  // 5 - 'Clubs'
+  5, 0x1c, 0x5e, 0x7f, 0x5e, 0x1c,  // 6 - 'Spades'
+  4, 0x00, 0x18, 0x3c, 0x18,  // 7 - 'Bullet Point'
+  5, 0xff, 0xe7, 0xc3, 0xe7, 0xff,  // 8 - 'Rev Bullet Point'
+  4, 0x00, 0x18, 0x24, 0x18,  // 9 - 'Hollow Bullet Point'
+  5, 0xff, 0xe7, 0xdb, 0xe7, 0xff,  // 10 - 'Rev Hollow BP'
+  5, 0x30, 0x48, 0x3a, 0x06, 0x0e,  // 11 - 'Male'
+  5, 0x26, 0x29, 0x79, 0x29, 0x26,  // 12 - 'Female'
+  5, 0x40, 0x7f, 0x05, 0x05, 0x07,  // 13 - 'Music Note 1'
+  5, 0x40, 0x7f, 0x05, 0x25, 0x3f,  // 14 - 'Music Note 2'
+  3, 2, 5, 2,  // 15 - degree sign
+  5, 0x7f, 0x3e, 0x1c, 0x1c, 0x08,  // 16 - 'Right Pointer'
+  5, 0x08, 0x1c, 0x1c, 0x3e, 0x7f,  // 17 - 'Left Pointer'
+  5, 0x14, 0x22, 0x7f, 0x22, 0x14,  // 18 - 'UpDown Arrows'
+  5, 0x5f, 0x5f, 0x00, 0x5f, 0x5f,  // 19 - 'Double Exclamation'
+  5, 0x06, 0x09, 0x7f, 0x01, 0x7f,  // 20 - 'Paragraph Mark'
+  4, 0x66, 0x89, 0x95, 0x6a,  // 21 - 'Section Mark'
+  5, 0x60, 0x60, 0x60, 0x60, 0x60,  // 22 - 'Double Underline'
+  5, 0x94, 0xa2, 0xff, 0xa2, 0x94,  // 23 - 'UpDown Underlined'
+  5, 0x08, 0x04, 0x7e, 0x04, 0x08,  // 24 - 'Up Arrow'
+  5, 0x10, 0x20, 0x7e, 0x20, 0x10,  // 25 - 'Down Arrow'
+  5, 0x08, 0x08, 0x2a, 0x1c, 0x08,  // 26 - 'Right Arrow'
+  5, 0x08, 0x1c, 0x2a, 0x08, 0x08,  // 27 - 'Left Arrow'
+  5, 0x1e, 0x10, 0x10, 0x10, 0x10,  // 28 - 'Angled'
+  5, 0x0c, 0x1e, 0x0c, 0x1e, 0x0c,  // 29 - 'Squashed #'
+  5, 0x30, 0x38, 0x3e, 0x38, 0x30,  // 30 - 'Up Pointer'
+  5, 0x06, 0x0e, 0x3e, 0x0e, 0x06,  // 31 - 'Down Pointer'
+  2, 0x00, 0x00, // 32 - 'Space'
+  1, 0x5f, // 33 - '!'
+  3, 0x07, 0x00, 0x07,  // 34 - '"'
+  5, 0x14, 0x7f, 0x14, 0x7f, 0x14,  // 35 - '#'
+  5, 0x24, 0x2a, 0x7f, 0x2a, 0x12,  // 36 - '$'
+  5, 0x23, 0x13, 0x08, 0x64, 0x62,  // 37 - '%'
+  5, 0x36, 0x49, 0x56, 0x20, 0x50,  // 38 - '&'
+  3, 0x08, 0x07, 0x03,  // 39 - '''
+  3, 0x1c, 0x22, 0x41,  // 40 - '('
+  3, 0x41, 0x22, 0x1c,  // 41 - ')'
+  5, 0x2a, 0x1c, 0x7f, 0x1c, 0x2a,  // 42 - '*'
+  5, 0x08, 0x08, 0x3e, 0x08, 0x08,  // 43 - '+'
+  3, 0x80, 0x70, 0x30,  // 44 - ','
+  5, 0x08, 0x08, 0x08, 0x08, 0x08,  // 45 - '-'
+  2, 0x60, 0x60,  // 46 - '.'
+  5, 0x20, 0x10, 0x08, 0x04, 0x02,  // 47 - '/'
+  5, 0x3e, 0x51, 0x49, 0x45, 0x3e,  // 48 - '0'
+  3, 0x42, 0x7f, 0x40,  // 49 - '1'
+  5, 0x72, 0x49, 0x49, 0x49, 0x46,  // 50 - '2'
+  5, 0x21, 0x41, 0x49, 0x4d, 0x33,  // 51 - '3'
+  5, 0x18, 0x14, 0x12, 0x7f, 0x10,  // 52 - '4'
+  5, 0x27, 0x45, 0x45, 0x45, 0x39,  // 53 - '5'
+  5, 0x3c, 0x4a, 0x49, 0x49, 0x31,  // 54 - '6'
+  5, 0x41, 0x21, 0x11, 0x09, 0x07,  // 55 - '7'
+  5, 0x36, 0x49, 0x49, 0x49, 0x36,  // 56 - '8'
+  5, 0x46, 0x49, 0x49, 0x29, 0x1e,  // 57 - '9'
+  1, 0x14,  // 58 - ':'
+  2, 0x80, 0x68,  // 59 - ';'
+  4, 0x08, 0x14, 0x22, 0x41,  // 60 - '<'
+  5, 0x14, 0x14, 0x14, 0x14, 0x14,  // 61 - '='
+  4, 0x41, 0x22, 0x14, 0x08,  // 62 - '>'
+  5, 0x02, 0x01, 0x59, 0x09, 0x06,  // 63 - '?'
+  5, 0x3e, 0x41, 0x5d, 0x59, 0x4e,  // 64 - '@'
+  5, 0x7c, 0x12, 0x11, 0x12, 0x7c,  // 65 - 'A'
+  5, 0x7f, 0x49, 0x49, 0x49, 0x36,  // 66 - 'B'
+  5, 0x3e, 0x41, 0x41, 0x41, 0x22,  // 67 - 'C'
+  5, 0x7f, 0x41, 0x41, 0x41, 0x3e,  // 68 - 'D'
+  5, 0x7f, 0x49, 0x49, 0x49, 0x41,  // 69 - 'E'
+  5, 0x7f, 0x09, 0x09, 0x09, 0x01,  // 70 - 'F'
+  5, 0x3e, 0x41, 0x41, 0x51, 0x73,  // 71 - 'G'
+  5, 0x7f, 0x08, 0x08, 0x08, 0x7f,  // 72 - 'H'
+  3, 0x41, 0x7f, 0x41,  // 73 - 'I'
+  5, 0x20, 0x40, 0x41, 0x3f, 0x01,  // 74 - 'J'
+  5, 0x7f, 0x08, 0x14, 0x22, 0x41,  // 75 - 'K'
+  5, 0x7f, 0x40, 0x40, 0x40, 0x40,  // 76 - 'L'
+  5, 0x7f, 0x02, 0x1c, 0x02, 0x7f,  // 77 - 'M'
+  5, 0x7f, 0x04, 0x08, 0x10, 0x7f,  // 78 - 'N'
+  5, 0x3e, 0x41, 0x41, 0x41, 0x3e,  // 79 - 'O'
+  5, 0x7f, 0x09, 0x09, 0x09, 0x06,  // 80 - 'P'
+  5, 0x3e, 0x41, 0x51, 0x21, 0x5e,  // 81 - 'Q'
+  5, 0x7f, 0x09, 0x19, 0x29, 0x46,  // 82 - 'R'
+  5, 0x26, 0x49, 0x49, 0x49, 0x32,  // 83 - 'S'
+  5, 0x03, 0x01, 0x7f, 0x01, 0x03,  // 84 - 'T'
+  5, 0x3f, 0x40, 0x40, 0x40, 0x3f,  // 85 - 'U'
+  5, 0x1f, 0x20, 0x40, 0x20, 0x1f,  // 86 - 'V'
+  5, 0x3f, 0x40, 0x38, 0x40, 0x3f,  // 87 - 'W'
+  5, 0x63, 0x14, 0x08, 0x14, 0x63,  // 88 - 'X'
+  5, 0x03, 0x04, 0x78, 0x04, 0x03,  // 89 - 'Y'
+  5, 0x61, 0x59, 0x49, 0x4d, 0x43,  // 90 - 'Z'
+  3, 0x7f, 0x41, 0x41,  // 91 - '['
+  5, 0x02, 0x04, 0x08, 0x10, 0x20,  // 92 - '\'
+  3, 0x41, 0x41, 0x7f,  // 93 - ']'
+  5, 0x04, 0x02, 0x01, 0x02, 0x04,  // 94 - '^'
+  5, 0x40, 0x40, 0x40, 0x40, 0x40,  // 95 - '_'
+  3, 0x03, 0x07, 0x08,  // 96 - '`'
+  5, 0x20, 0x54, 0x54, 0x78, 0x40,  // 97 - 'a'
+  5, 0x7f, 0x28, 0x44, 0x44, 0x38,  // 98 - 'b'
+  5, 0x38, 0x44, 0x44, 0x44, 0x28,  // 99 - 'c'
+  5, 0x38, 0x44, 0x44, 0x28, 0x7f,  // 100 - 'd'
+  5, 0x38, 0x54, 0x54, 0x54, 0x18,  // 101 - 'e'
+  4, 0x08, 0x7e, 0x09, 0x02,  // 102 - 'f'
+  5, 0x18, 0xa4, 0xa4, 0x9c, 0x78,  // 103 - 'g'
+  5, 0x7f, 0x08, 0x04, 0x04, 0x78,  // 104 - 'h'
+  3, 0x44, 0x7d, 0x40,  // 105 - 'i'
+  4, 0x40, 0x80, 0x80, 0x7a,  // 106 - 'j'
+  4, 0x7f, 0x10, 0x28, 0x44,  // 107 - 'k'
+  3, 0x41, 0x7f, 0x40,  // 108 - 'l'
+  5, 0x7c, 0x04, 0x78, 0x04, 0x78,  // 109 - 'm'
+  5, 0x7c, 0x08, 0x04, 0x04, 0x78,  // 110 - 'n'
+  5, 0x38, 0x44, 0x44, 0x44, 0x38,  // 111 - 'o'
+  5, 0xfc, 0x18, 0x24, 0x24, 0x18,  // 112 - 'p'
+  5, 0x18, 0x24, 0x24, 0x18, 0xfc,  // 113 - 'q'
+  5, 0x7c, 0x08, 0x04, 0x04, 0x08,  // 114 - 'r'
+  5, 0x48, 0x54, 0x54, 0x54, 0x24,  // 115 - 's'
+  4, 0x04, 0x3f, 0x44, 0x24,  // 116 - 't'
+  5, 0x3c, 0x40, 0x40, 0x20, 0x7c,  // 117 - 'u'
+  5, 0x1c, 0x20, 0x40, 0x20, 0x1c,  // 118 - 'v'
+  5, 0x3c, 0x40, 0x30, 0x40, 0x3c,  // 119 - 'w'
+  5, 0x44, 0x28, 0x10, 0x28, 0x44,  // 120 - 'x'
+  5, 0x4c, 0x90, 0x90, 0x90, 0x7c,  // 121 - 'y'
+  5, 0x44, 0x64, 0x54, 0x4c, 0x44,  // 122 - 'z'
+  3, 0x08, 0x36, 0x41,  // 123 - '{'
+  1, 0x77,  // 124 - '|'
+  3, 0x41, 0x36, 0x08,  // 125 - '}'
+  5, 0x02, 0x01, 0x02, 0x04, 0x02,  // 126 - '~'
+  5, 0x3c, 0x26, 0x23, 0x26, 0x3c,  // 127 - 'Hollow Up Arrow'
+  5, 0x1e, 0xa1, 0xa1, 0x61, 0x12,  // 128 - 'C sedilla'
+  5, 0x38, 0x42, 0x40, 0x22, 0x78,  // 129 - 'u umlaut'
+  5, 0x38, 0x54, 0x54, 0x55, 0x59,  // 130 - 'e acute'
+  5, 0x21, 0x55, 0x55, 0x79, 0x41,  // 131 - 'a accent'
+  5, 0x21, 0x54, 0x54, 0x78, 0x41,  // 132 - 'a umlaut'
+  5, 0x21, 0x55, 0x54, 0x78, 0x40,  // 133 - 'a grave'
+  5, 0x20, 0x54, 0x55, 0x79, 0x40,  // 134 - 'a acute'
+  5, 0x18, 0x3c, 0xa4, 0xe4, 0x24,  // 135 - 'c sedilla'
+  5, 0x39, 0x55, 0x55, 0x55, 0x59,  // 136 - 'e accent'
+  5, 0x38, 0x55, 0x54, 0x55, 0x58,  // 137 - 'e umlaut'
+  5, 0x39, 0x55, 0x54, 0x54, 0x58,  // 138 - 'e grave'
+  3, 0x45, 0x7c, 0x41,  // 139 - 'i umlaut'
+  4, 0x02, 0x45, 0x7d, 0x42,  // 140 - 'i hat'
+  4, 0x01, 0x45, 0x7c, 0x40,  // 141 - 'i grave'
+  5, 0xf0, 0x29, 0x24, 0x29, 0xf0,  // 142 - 'A umlaut'
+  5, 0xf0, 0x28, 0x25, 0x28, 0xf0,  // 143 - 'A dot'
+  4, 0x7c, 0x54, 0x55, 0x45,  // 144 - 'E grave'
+  7, 0x20, 0x54, 0x54, 0x7c, 0x54, 0x54, 0x08,  // 145 - 'ae'
+  6, 0x7c, 0x0a, 0x09, 0x7f, 0x49, 0x49,  // 146 - 'AE'
+  5, 0x32, 0x49, 0x49, 0x49, 0x32,  // 147 - 'o hat'
+  5, 0x30, 0x4a, 0x48, 0x4a, 0x30,  // 148 - 'o umlaut'
+  5, 0x32, 0x4a, 0x48, 0x48, 0x30,  // 149 - 'o grave'
+  5, 0x3a, 0x41, 0x41, 0x21, 0x7a,  // 150 - 'u hat'
+  5, 0x3a, 0x42, 0x40, 0x20, 0x78,  // 151 - 'u grave'
+  4, 0x9d, 0xa0, 0xa0, 0x7d,  // 152 - 'y umlaut'
+  5, 0x38, 0x45, 0x44, 0x45, 0x38,  // 153 - 'O umlaut'
+  5, 0x3c, 0x41, 0x40, 0x41, 0x3c,  // 154 - 'U umlaut'
+  5, 0x3c, 0x24, 0xff, 0x24, 0x24,  // 155 - 'Cents'
+  5, 0x48, 0x7e, 0x49, 0x43, 0x66,  // 156 - 'Pounds'
+  5, 0x2b, 0x2f, 0xfc, 0x2f, 0x2b,  // 157 - 'Yen'
+  5, 0xff, 0x09, 0x29, 0xf6, 0x20,  // 158 - 'R +'
+  5, 0xc0, 0x88, 0x7e, 0x09, 0x03,  // 159 - 'f notation'
+  5, 0x20, 0x54, 0x54, 0x79, 0x41,  // 160 - 'a acute'
+  3, 0x44, 0x7d, 0x41,  // 161 - 'i acute'
+  5, 0x30, 0x48, 0x48, 0x4a, 0x32,  // 162 - 'o acute'
+  5, 0x38, 0x40, 0x40, 0x22, 0x7a,  // 163 - 'u acute'
+  4, 0x7a, 0x0a, 0x0a, 0x72,  // 164 - 'n accent'
+  5, 0x7d, 0x0d, 0x19, 0x31, 0x7d,  // 165 - 'N accent'
+  5, 0x26, 0x29, 0x29, 0x2f, 0x28,  // 166
+  5, 0x26, 0x29, 0x29, 0x29, 0x26,  // 167
+  5, 0x30, 0x48, 0x4d, 0x40, 0x20,  // 168 - 'Inverted ?'
+  5, 0x38, 0x08, 0x08, 0x08, 0x08,  // 169 - 'LH top corner'
+  5, 0x08, 0x08, 0x08, 0x08, 0x38,  // 170 - 'RH top corner'
+  5, 0x2f, 0x10, 0xc8, 0xac, 0xba,  // 171 - '1/2'
+  5, 0x2f, 0x10, 0x28, 0x34, 0xfa,  // 172 - '1/4'
+  1, 0x7b,  // 173 - '| split'
+  5, 0x08, 0x14, 0x2a, 0x14, 0x22,  // 174 - '<<'
+  5, 0x22, 0x14, 0x2a, 0x14, 0x08,  // 175 - '>>'
+  5, 0xaa, 0x00, 0x55, 0x00, 0xaa,  // 176 - '30% shading'
+  5, 0xaa, 0x55, 0xaa, 0x55, 0xaa,  // 177 - '50% shading'
+  5, 0x00, 0x00, 0x00, 0x00, 0xff,  // 178 - 'Right side'
+  5, 0x10, 0x10, 0x10, 0x10, 0xff,  // 179 - 'Right T'
+  5, 0x14, 0x14, 0x14, 0x14, 0xff,  // 180 - 'Right T double H'
+  5, 0x10, 0x10, 0xff, 0x00, 0xff,  // 181 - 'Right T double V'
+  5, 0x10, 0x10, 0xf0, 0x10, 0xf0,  // 182 - 'Top Right double V'
+  5, 0x14, 0x14, 0x14, 0x14, 0xfc,  // 183 - 'Top Right double H'
+  5, 0x14, 0x14, 0xf7, 0x00, 0xff,  // 184 - 'Right T double all'
+  5, 0x00, 0x00, 0xff, 0x00, 0xff,  // 185 - 'Right side double'
+  5, 0x14, 0x14, 0xf4, 0x04, 0xfc,  // 186 - 'Top Right double'
+  5, 0x14, 0x14, 0x17, 0x10, 0x1f,  // 187 - 'Bot Right double'
+  5, 0x10, 0x10, 0x1f, 0x10, 0x1f,  // 188 - 'Bot Right double V'
+  5, 0x14, 0x14, 0x14, 0x14, 0x1f,  // 189 - 'Bot Right double H'
+  5, 0x10, 0x10, 0x10, 0x10, 0xf0,  // 190 - 'Top Right'
+  5, 0x00, 0x00, 0x00, 0x1f, 0x10,  // 191 - 'Bot Left'
+  5, 0x7e, 0x11, 0x11, 0x11, 0x7e,             // 192 - '?'  x0c0
+  5, 0x7f, 0x49, 0x49, 0x49, 0x31,             // 193 - '?'  x0c1
+  5, 0x7f, 0x49, 0x49, 0x49, 0x36,             // 194 - 'B'  x0c2
+  5, 0x7f, 0x01, 0x01, 0x01, 0x03,             // 195 - '?'  x0c3
+  6, 0xc0, 0x7e, 0x41, 0x41, 0x7e, 0xc0,       // 196 - '?'  x0c4
+  5, 0x7f, 0x49, 0x49, 0x49, 0x41,             // 197 - 'E'  x0c5
+  5, 0x77, 0x08, 0x7f, 0x08, 0x77,             // 198 - '?'  x0c6
+  5, 0x41, 0x49, 0x49, 0x49, 0x36,             // 199 - '?'  x0c7
+  5, 0x7f, 0x10, 0x08, 0x04, 0x7f,             // 200 - '?'  x0c8
+  5, 0x7f, 0x10, 0x09, 0x04, 0x7f,             // 201 - '?'  x0c9
+  5, 0x7f, 0x08, 0x14, 0x22, 0x41,             // 202 - 'K'  x0ca
+  5, 0x40, 0x3e, 0x01, 0x01, 0x7f,             // 203 - '?'  x0cb
+  5, 0x7f, 0x02, 0x0c, 0x02, 0x7f,             // 204 - 'M'  x0cc
+  5, 0x7f, 0x08, 0x08, 0x08, 0x7f,             // 205 - 'H'  x0cd
+  5, 0x3e, 0x41, 0x41, 0x41, 0x3e,             // 206 - 'O'  x0ce
+  5, 0x7f, 0x01, 0x01, 0x01, 0x7f,             // 207 - '?'  x0cf
+  5, 0x7f, 0x09, 0x09, 0x09, 0x06,             // 208 - '?'  x0d0
+  5, 0x3e, 0x41, 0x41, 0x41, 0x22,             // 209 - 'C'  x0d1
+  5, 0x03, 0x01, 0x7f, 0x01, 0x03,             // 210 - 'T'  x0d2
+  5, 0x27, 0x48, 0x48, 0x48, 0x3f,             // 211 - '?'  x0d3
+  7, 0x1c, 0x22, 0x22, 0x7f, 0x22, 0x22, 0x1c, // 212 - '?'  x0d4
+  5, 0x63, 0x14, 0x08, 0x14, 0x63,             // 213 - 'X'  x0d5
+  6, 0x7f, 0x40, 0x40, 0x40, 0x7f, 0xc0,       // 214 - '?'  x0d6
+  5, 0x07, 0x08, 0x08, 0x08, 0x7f,             // 215 - '?'  x0d7
+  5, 0x7f, 0x40, 0x7f, 0x40, 0x7f,             // 216 - '?'  x0d8
+  6, 0x7f, 0x40, 0x7f, 0x40, 0x7f, 0xc0,       // 217 - '?'  x0d9
+  7, 0x01, 0x01, 0x7f, 0x48, 0x48, 0x48, 0x30, // 218 - '?'  x0da
+  5, 0x7f, 0x48, 0x48, 0x30, 0x7f,             // 219 - '?'  x0db
+  5, 0x7f, 0x48, 0x48, 0x48, 0x30,             // 220 - '?'  x0dc
+  5, 0x22, 0x41, 0x49, 0x49, 0x3e,             // 221 - '?'  x0dd
+  6, 0x7f, 0x08, 0x3e, 0x41, 0x41, 0x3e,       // 222 - '?'  x0de
+  5, 0x46, 0x29, 0x19, 0x09, 0x7f,             // 223 - '?'  x0df
+  5, 0x20, 0x54, 0x54, 0x54, 0x78,             // 224 - '?'  x0e0
+  5, 0x3c, 0x4a, 0x4a, 0x49, 0x31,             // 225 - '?'  x0e1
+  5, 0x7c, 0x54, 0x54, 0x54, 0x28,             // 226 - '?'  x0e2
+  4, 0x7c, 0x04, 0x04, 0x0c,                   // 227 - '?'  x0e3
+  6, 0xc0, 0x78, 0x44, 0x44, 0x78, 0xc0,       // 228 - '?'  x0e4
+  5, 0x38, 0x54, 0x54, 0x54, 0x18,             // 229 - 'e'  x0e5
+  5, 0x6c, 0x10, 0x7c, 0x10, 0x6c,             // 230 - '?'  x0e6
+  5, 0x44, 0x54, 0x54, 0x54, 0x28,             // 231 - '?'  x0e7
+  5, 0x7c, 0x20, 0x10, 0x08, 0x7c,             // 232 - '?'  x0e8
+  5, 0x7c, 0x40, 0x26, 0x10, 0x7c,             // 233 - '?'  x0e9
+  5, 0x7c, 0x10, 0x10, 0x28, 0x44,             // 234 - '?'  x0ea
+  5, 0x40, 0x38, 0x04, 0x04, 0x7c,             // 235 - '?'  x0eb
+  5, 0x7c, 0x08, 0x10, 0x08, 0x7c,             // 236 - '?'  x0ec
+  5, 0x7c, 0x10, 0x10, 0x10, 0x7c,             // 237 - '?'  x0ed
+  5, 0x38, 0x44, 0x44, 0x44, 0x38,             // 238 - 'o'  x0ee
+  5, 0x7c, 0x04, 0x04, 0x04, 0x7c,             // 239 - '?'  x0ef
+  5, 0xfc, 0x24, 0x24, 0x24, 0x18,             // 240 - '?'  x0f0
+  4, 0x38, 0x44, 0x44, 0x44,                   // 241 - '?'  x0f1
+  5, 0x04, 0x04, 0x7c, 0x04, 0x04,             // 242 - '?'  x0f2
+  5, 0x0c, 0x90, 0x90, 0x90, 0x7c,             // 243 - '?'  x0f3
+  7, 0x10, 0x28, 0x28, 0xfc, 0x28, 0x28, 0x10, // 244 - '?'  x0f4
+  5, 0x44, 0x28, 0x10, 0x28, 0x44,             // 245 - 'x'  x0f5
+  5, 0x7c, 0x40, 0x40, 0x7c, 0xc0,             // 246 - '?'  x0f6
+  5, 0x0c, 0x10, 0x10, 0x10, 0x7c,             // 247 - '?'  x0f7
+  5, 0x7c, 0x40, 0x7c, 0x40, 0x7c,             // 248 - '?'  x0f8
+  6, 0x7c, 0x40, 0x7c, 0x40, 0x7c, 0xc0,       // 249 - '?'  x0f9
+  6, 0x04, 0x7c, 0x50, 0x50, 0x50, 0x20,       // 250 - '?'  x0fa
+  5, 0x7c, 0x50, 0x50, 0x20, 0x7c,             // 251 - '?'  x0fb
+  5, 0x7c, 0x50, 0x50, 0x50, 0x20,             // 252 - '?'  x0fc
+  5, 0x28, 0x44, 0x54, 0x54, 0x38,             // 253 - '?'  x0fd
+  6, 0x7c, 0x10, 0x38, 0x44, 0x44, 0x38,       // 254 - '?'  x0fe
+  5, 0x48, 0x34, 0x14, 0x14, 0x7c              // 255 - '?'  x0ff
+};
+
+MD_MAX72XX::fontType_t* fonts[] PROGMEM = {
+  (MD_MAX72XX::fontType_t*)&NumbersStandard,
+  (MD_MAX72XX::fontType_t*)&Numbers5x8,
+  (MD_MAX72XX::fontType_t*)&NumbersThin,
+  (MD_MAX72XX::fontType_t*)&NumbersBold,
+  (MD_MAX72XX::fontType_t*)&NumbersBoldStraight,
+  (MD_MAX72XX::fontType_t*)&NumbersSinclair  
+};
diff --git a/hardware.cpp b/hardware.cpp
new file mode 100644 (file)
index 0000000..0153632
--- /dev/null
@@ -0,0 +1,204 @@
+#include "Clock.h"
+#include <Wire.h>
+#include "time.h"
+#include <Button2.h>
+#include <Arduino.h>
+#include <coredecls.h>
+
+RTC_DS3231 RTC;
+bool isRTCEnabled = false;
+
+Button2 btn;
+bool isButtonEnabled = false;
+
+bool isBuzzerEnabled = false;
+int buzzer_pin;
+bool buzzer_passive;
+
+bool beepState = false;
+bool beepStateRequested = false;
+
+int beepToneRequested;
+int beepLengthRequested;
+
+int beepMs = 400;
+int silentMs = 200;
+int beepTone = 1000;
+
+void beep(int tone, int length, int beep, int silent) {
+  beepStateRequested = true;
+  beepToneRequested = tone;
+  beepLengthRequested = length;
+  if (!silent && beep>length) {
+    beepMs = length;
+  } else {
+    beepMs = beep;
+  }
+  silentMs = silent;
+}
+
+unsigned long first_click_millis = 0;
+
+void buttonHandler(Button2& btn) {
+  if (!isButtonEnabled) return;
+  if (beepStateRequested) {
+    beepStateRequested = false;
+    return;
+  }
+  clickType click = btn.read();
+  Serial.println(btn.clickToString(click));
+  switch (click) {
+    case single_click:
+      screenModeRequested++;
+      if (screenModeRequested == mBarrier) { screenModeRequested = mDefault; }
+      if (screenModeRequested == mLast) { screenModeRequested = mDefault; }
+      break;
+    case double_click:
+      screenModeRequested--;
+      if (screenModeRequested <= mDefault) { screenModeRequested = mBarrier - 1; }
+      break;
+    case triple_click:
+      static int click_counter = 0;
+      if (millis() - first_click_millis > 6000) {
+        first_click_millis = millis();
+        click_counter = 1;
+        beep(400,800,200,100);
+        message(F("Длинное - точка доступа, тройное - сброс"));
+      } else {
+        message(F("Еще раз для сброса"));
+        beep(800,800,200,100);
+        click_counter++;
+        if (click_counter>2) {
+          reportMessage(F("Сбрасываю настройки"));
+          tone(1200,1000);
+          Serial.println(F("Три тройных нажатия - сбрасываю конфигурацию"));
+          reset();
+        }
+      }
+      break;
+  }
+}
+
+void buttonLongClickHandler(Button2& btn) {
+  if (!isButtonEnabled) return;
+  if (beepStateRequested) {
+    beepStateRequested = false;
+    return;
+  }
+  if (millis() - first_click_millis < 6000) {
+    if (isApEnabled) {
+      setupNet(false);
+    } else {
+      setupNet(true);
+    }
+    return;
+  }
+  bool enable_alarm = cfg.getBoolValue(F("enable_alarm"));
+  enable_alarm = !enable_alarm;
+  cfg.setValue(F("enable_alarm"), enable_alarm);
+  Serial.println(F("Alarm = ")); Serial.println(enable_alarm);
+  if (enable_alarm) {
+    message(F("Будильник включен"));
+    reportChange(F("enable_alarm"));
+    reportMessage(F("Будильник включен"));
+  } else {
+    message(F("Будильник выключен"));
+    reportChange(F("enable_alarm"));
+    reportMessage(F("Будильник выключен"));
+  }
+
+}
+
+void setupHardware() {
+  int pin_sda = cfg.getIntValue(F("pin_sda"));
+  int pin_scl = cfg.getIntValue(F("pin_scl"));
+  if (pin_sda && pin_scl && cfg.getBoolValue(F("enable_rtc"))) {
+    Serial.println(F("Нестандартные пины i2c"));
+    Wire.begin(pin_sda,pin_scl);
+  }
+  int i2c_speed = cfg.getIntValue(F("i2c_speed"));
+  if (i2c_speed) Wire.setClock(i2c_speed);
+  if (cfg.getBoolValue(F("enable_rtc"))) {
+    RTC.begin();
+    time_t rtc = RTC.now().unixtime();
+    timeval tv = { rtc, 0 };
+    settimeofday(&tv, nullptr);
+    isRTCEnabled = true;
+    Serial.println(F("Время установлено по встроенным часам"));
+  }
+  if (cfg.getBoolValue(F("enable_button"))) {
+    int button_pin = cfg.getIntValue(F("button_pin"));
+    btn.begin(button_pin, INPUT_PULLUP, !cfg.getBoolValue("button_inversed"));
+    btn.setLongClickTime(700);
+    btn.setDoubleClickTime(500);
+    btn.setClickHandler(&buttonHandler);
+    btn.setDoubleClickHandler(&buttonHandler);
+    btn.setTripleClickHandler(&buttonHandler);
+    btn.setLongClickDetectedHandler(&buttonLongClickHandler);
+    isButtonEnabled = true;
+  } else {
+    isButtonEnabled = false;
+  }
+  if (cfg.getBoolValue(F("enable_buzzer"))) {
+    isBuzzerEnabled = true;
+    buzzer_pin = cfg.getIntValue(F("buzzer_pin"));
+    pinMode(buzzer_pin,OUTPUT);
+    buzzer_passive = cfg.getBoolValue(F("buzzer_passive"));
+  } else {
+    isBuzzerEnabled = true;
+  }
+}
+
+void doBeep(int note, int duration) {
+  tone(buzzer_pin, note, duration);
+}
+
+void tickHardware() {
+  if (isButtonEnabled) {
+    btn.loop();
+  }
+  if (isBuzzerEnabled) {
+    static unsigned long stopBeepMillis = 0;
+    if (stopBeepMillis && millis() >= stopBeepMillis) {
+      beepStateRequested = false;
+      stopBeepMillis = 0;
+    }
+    if (beepStateRequested != beepState && beepStateRequested) {
+      stopBeepMillis = millis() + beepLengthRequested;
+    }
+    if (buzzer_passive) {
+      static unsigned long nextmillis;
+      if (beepStateRequested != beepState) {
+        beepState = beepStateRequested;
+        nextmillis = 0;
+      }
+      if (beepState) {
+        if (millis() > nextmillis) {
+          doBeep(beepToneRequested, beepMs);
+          nextmillis = millis() + beepMs + silentMs;
+        }
+      }
+    } else {
+      static unsigned long nextonmillis;
+      static unsigned long nextoffmillis;
+      if (beepStateRequested != beepState) {
+        beepState = beepStateRequested;
+        nextonmillis = 0;
+        nextoffmillis = 0;
+      }
+      if (beepState) {
+        static unsigned pinState = 0;
+        if (millis() > nextonmillis) {
+          digitalWrite(buzzer_pin, HIGH);
+          nextoffmillis = nextonmillis + beepMs;
+          nextonmillis = nextonmillis + beepMs + silentMs;
+        } else if (millis() > nextoffmillis) {
+          digitalWrite(buzzer_pin, LOW);
+          nextoffmillis = nextonmillis;
+        }
+      } else {
+        digitalWrite(buzzer_pin, LOW);
+      }
+    }
+  }
+}
diff --git a/make_fs.sh b/make_fs.sh
new file mode 100644 (file)
index 0000000..5ef8196
--- /dev/null
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+PORT=/dev/ttyUSB1
+
+mkdir -p ./.data-release
+cp -r ./data/* ./.data-release/
+
+cat ui.yml | yq -c > ./.data-release/ui.json
+
+find ./.data-release/ -name "*~" -type f -delete
+find ./.data-release/web/ -type f -name "*.css" ! -name "*.min.*" -exec echo {} \; -exec uglifycss --output {}.min {} \; -exec rm {} \; -exec mv {}.min {} \;
+find ./.data-release/web/ -type f -name "*.js" ! -name "*.min.*" ! -name "vfs_fonts*" -exec echo {} \; -exec uglifyjs -o {}.min {} \; -exec rm {} \; -exec mv {}.min {} \;
+find ./.data-release/web -type f -exec gzip {} +
+
+mklittlefs -c ./.data-release littlefs.img -d 5 -b 8192 -p 256 -s 0xfb000
+esptool.py --port $PORT write_flash 0x300000 littlefs.img 
+rm -r ./.data-release
diff --git a/net.cpp b/net.cpp
new file mode 100644 (file)
index 0000000..ff18fcc
--- /dev/null
+++ b/net.cpp
@@ -0,0 +1,55 @@
+#include "Clock.h"
+
+bool isNetConnected = false;
+const char* SSID;
+const char* PSK;
+
+const char* apMsg1 PROGMEM = "Активирована точка доступа ";
+const char* apMsg2 PROGMEM = ", пароль ";
+const char* apMsg3 PROGMEM = ", IP-адрес ";
+
+void setupNet(bool AP) {
+  SSID = cfg.getCharValue(F("sta_ssid"));
+  PSK = cfg.getCharValue(F("sta_psk"));
+  if (SSID && SSID[0] && !AP) {
+    WiFi.mode(WIFI_STA);
+    WiFi.begin(SSID,PSK);
+    isApEnabled = false;
+    Serial.println(F("Режим беспроводного клиента"));
+  } else {
+    SSID = cfg.getCharValue(F("ap_ssid"));
+    PSK = cfg.getCharValue(F("ap_psk"));
+    if (!SSID || !SSID[0]) {
+      SSID = "WiFi Clock";
+    }
+    WiFi.mode(WIFI_AP);
+    WiFi.softAP(SSID,PSK);
+    Serial.println(F("Режим точки доступа"));
+    isApEnabled = true;
+    String IP = WiFi.softAPIP().toString();
+    char buf[256];
+    strcpy_P(buf, apMsg1);
+    strcat(buf, SSID);
+    if (PSK && PSK[0]) {
+      strcat_P(buf, apMsg2);
+      strcat(buf, PSK);
+    }
+    strcat_P(buf, apMsg3);
+    strcat(buf, IP.c_str());
+    message(buf);
+  }
+}
+
+void tickNet() {
+  if (isApEnabled) {
+    if (WiFi.status() == WL_CONNECTED && !isNetConnected) {
+      isNetConnected = true;
+      Serial.print(F("Выполнено подключение к сети ")); Serial.println(SSID);
+      message(F("Соединение установлено"));
+    } else if (WiFi.status() != WL_CONNECTED && isNetConnected) {
+      isNetConnected = false;
+      Serial.println(F("Сеть потеряна"));
+      message(F("Сеть потеряна"));
+    }
+  }
+}
\ No newline at end of file
diff --git a/panel.cpp b/panel.cpp
new file mode 100644 (file)
index 0000000..fc07261
--- /dev/null
+++ b/panel.cpp
@@ -0,0 +1,290 @@
+#include "Clock.h"
+#include <MD_Parola.h>
+#include <MD_MAX72xx.h>
+#include "fonts.h"
+
+#define FONT_NARROW 2
+
+int screenMode = mTime;
+int screenModeRequested = mTime;
+int currentPriority = 255;
+bool scrollFinished = true;
+char msgBuf[256];   // one-time messages
+char scrollBuf[256] = "- - - - -"; // retained
+
+const char mn1[] PROGMEM = "января";
+const char mn2[] PROGMEM = "февраля";
+const char mn3[] PROGMEM = "марта";
+const char mn4[] PROGMEM = "апреля";
+const char mn5[] PROGMEM = "мая";
+const char mn6[] PROGMEM = "июня";
+const char mn7[] PROGMEM = "июля";
+const char mn8[] PROGMEM = "августа";
+const char mn9[] PROGMEM = "сентября";
+const char mn10[] PROGMEM = "октября";
+const char mn11[] PROGMEM = "ноября";
+const char mn12[] PROGMEM = "декабря";
+
+const char *const months[] PROGMEM = {
+  mn1, mn2, mn3, mn4, mn5, mn6, mn7, mn8, mn9, mn10, mn11, mn12
+};
+
+const char day0[] PROGMEM = "Воскресенье";
+const char day1[] PROGMEM = "Понедельник";
+const char day2[] PROGMEM = "Вторник";
+const char day3[] PROGMEM = "Среда";
+const char day4[] PROGMEM = "Четверг";
+const char day5[] PROGMEM = "Пятница";
+const char day6[] PROGMEM = "Суббота";
+
+const char* const days[] PROGMEM = {
+  day0, day1, day2, day3, day4, day5, day6
+};
+
+MD_Parola* Panel = nullptr;
+
+void utf8rus(const char* source, char* target, int maxLen) {
+  int i = 0, k , idx =0;
+  unsigned char n;
+  k = strlen(source);
+  while (i < k) {
+    n = source[i]; i++;
+    if (n >= 0xC0) {
+      switch (n) {
+        case 0xD0: {
+          n = source[i]; i++;
+          if (n == 0x81) { n = 0xA8; break; }
+          if (n >= 0x90 && n <= 0xBF) n = n + 0x30;
+          break;
+        }
+        case 0xD1: {
+          n = source[i]; i++;
+          if (n == 0x91) { n = 0xB8; break; }
+          if (n >= 0x80 && n <= 0x8F) n = n + 0x70;
+          break;
+        }
+        case 0xC2: {
+          n = source[i]; i++;
+          if (n == 0xb0) { n = 0xf; break; } // degree sign, hack
+          break;
+        }
+        default: {
+          n = source[i]; i++;
+        }
+      }
+    }
+    target[idx++] = n;
+    if (idx>=maxLen) break;
+  }
+  target[idx] = 0;
+}
+
+void setPanelBrightness() {
+  if (Panel) {
+    int br;
+    if (isNight()) {
+      br = cfg.getIntValue(F("panel_brightness_night"));
+    } else {
+      br = cfg.getIntValue(F("panel_brightness_day"));
+    }
+    Panel->setIntensity(br);
+  }
+}
+
+void setupPanel() {
+  if (Panel) {
+    Panel->displayClear();
+    delete Panel;
+    Panel=nullptr;
+  }
+  int pin_din = cfg.getIntValue(F("pin_din"));
+  int pin_clk = cfg.getIntValue(F("pin_clk"));
+  int pin_cs = cfg.getIntValue(F("pin_cs"));
+  if (!pin_din || !pin_clk || !pin_cs) {
+    pin_din = D6;
+    pin_clk = D8;
+    pin_cs = D7;
+  }
+  int led_modules = cfg.getIntValue(F("led_modules"));
+  if (!led_modules) led_modules = 4;
+  Panel = new MD_Parola(MD_MAX72XX::FC16_HW,pin_din,pin_clk,pin_cs,led_modules);
+  Panel->begin();
+  Panel->setFont(RomanCyrillic);
+  unregisterTimeHandler(F("brightness"));
+  registerTimeHandler(F("brightness"),'h',setPanelBrightness);
+}
+
+int panelSpeed() {
+  int panel_speed = cfg.getIntValue(F("panel_speed"));
+  if (panel_speed>0 && panel_speed<=20) {
+    panel_speed = 500/panel_speed;
+  } else  {
+    panel_speed = 50;
+  }
+  return panel_speed;
+}
+
+void drawScroll() {
+  Panel->displayClear();
+  Panel->setFont(RomanCyrillic);
+  Panel->displayScroll(scrollBuf, PA_CENTER, PA_SCROLL_LEFT, panelSpeed());
+  screenMode = mWeather;
+}
+
+void message(const char* str, int priority) {
+  if (priority > currentPriority) return;
+  currentPriority = priority;
+  utf8rus(str, msgBuf, 255);
+  Panel->displayClear();
+  Panel->setFont(RomanCyrillic);
+  Panel->displayScroll(msgBuf, PA_CENTER, PA_SCROLL_LEFT, panelSpeed());
+  screenModeRequested = mMessage;
+  screenMode = mMessage;
+}
+
+void message(const __FlashStringHelper*  str, int priority) {
+  if (priority > currentPriority) return;
+  currentPriority = priority;
+  char buf[256];
+  strncpy_P(buf, (PGM_P)str, 255);
+  utf8rus(buf, msgBuf, 255);
+  Panel->displayClear();
+  Panel->setFont(RomanCyrillic);
+  Panel->displayScroll(msgBuf, PA_CENTER, PA_SCROLL_LEFT, panelSpeed());
+  screenModeRequested = mMessage;
+  screenMode = mMessage;
+}
+
+void messageModal(const char* str) {
+  message(str);
+  while (!(Panel->displayAnimate())) { delay(10); }
+}
+
+void messageModal(const __FlashStringHelper*  str) {
+  message(str);
+  while (!(Panel->displayAnimate())) { delay(10); }
+}
+
+void scroll(const char* str, bool force) {
+  utf8rus(str, scrollBuf, 255);
+  if (force && currentPriority>5) {
+    drawScroll();
+    screenModeRequested = mWeather;
+  }
+}
+
+const char* templateSZ PROGMEM = "%02d%c%02d%c%02d";
+const char* templateSNZ PROGMEM = "%2d%c%02d%c%02d";
+const char* templateMZ PROGMEM = "%02d%c%02d";
+const char* templateMNZ PROGMEM = "%2d%c%02d";
+
+void displayTime(int hh, int mi, int ss, int dw, int dd, int mm, int yy, bool dots) {
+  char divider;
+  char templateStr[32];
+  Panel->setFont(fonts[
+     (cfg.getBoolValue(F("panel_seconds")) && (cfg.getIntValue(F("led_modules"))<6))
+     ?FONT_NARROW:cfg.getIntValue(F("panel_font")) ]);
+  if (screenMode !=mTime) {
+    screenModeRequested = mTime;
+    screenMode = mTime;
+  }
+  if (dots || !cfg.getBoolValue(F("flash_dots"))) {
+    divider = ':';
+  } else {
+    divider = ' ';
+  }
+  if (cfg.getBoolValue(F("panel_seconds"))) {
+    if (cfg.getBoolValue(F("panel_zero"))) {
+      strcpy_P(templateStr,templateSZ);
+    } else {
+      strcpy_P(templateStr,templateSNZ);
+    }
+  } else {
+    if (cfg.getBoolValue(F("panel_zero"))) {
+      strcpy_P(templateStr,templateMZ);
+    } else {
+      strcpy_P(templateStr,templateMNZ);
+    }
+  }
+  snprintf(msgBuf, 255, templateStr, hh, divider, mi, divider, ss);
+  Panel->displayText(msgBuf, PA_CENTER,100,0,PA_NO_EFFECT,PA_NO_EFFECT);
+  Panel->displayAnimate();
+  scrollFinished = true;
+}
+
+void displayTime() {
+
+  static bool newsec = false;
+  static bool newsec06 = false;
+  static int  prevss;
+  static unsigned long prevmilli;
+
+  if (ss != prevss) {
+    prevmilli=millis();
+    prevss = ss;
+    newsec = true;
+    newsec06 = true;
+  }
+
+  if (newsec) {
+    displayTime(hh,mi,ss,dw,dd,mm,yy,true);
+    newsec = false;
+  } else {
+    int delta = millis() - prevmilli;
+    if (delta>600 && newsec06) {
+      displayTime(hh,mi,ss,dw,dd,mm,yy,false);
+      newsec06 = false;
+    }
+  }
+
+}
+
+void displayDate(int dd, int mm, int yy) {
+
+  String msg;
+
+  if (screenMode !=mDate) {
+    screenMode = mDate;
+
+    PGM_P day = days[dw];
+    PGM_P month = months[mm];
+
+    sprintf(msgBuf,"%s, %02d %s %04d г",day, dd, month, yy);
+    utf8rus(msgBuf, msgBuf, 255);
+    Panel->displayClear();
+    Panel->setFont(RomanCyrillic);
+
+    Panel->displayScroll(msgBuf, PA_CENTER, PA_SCROLL_LEFT, panelSpeed());
+  }
+}
+
+void displayDate() {
+  displayDate(dd,mm,yy);
+}
+
+void tickPanel() {
+  if (screenMode == mTime || screenModeRequested != screenMode) { // needs to redraw
+    if (now<1668332133) { message(F("Время еще неизвестно...")); }
+    switch (screenModeRequested) {
+      case mDefault:
+      case mBarrier:
+      case mLast:
+      case mTime:
+        displayTime();
+        break;
+      case mDate:
+        displayDate();
+        break;
+      case mWeather:
+        drawScroll();
+        break;
+      case mMessage:
+        break;
+    }
+  } else if (Panel->displayAnimate()) {
+    screenModeRequested = mTime;
+    currentPriority = 255;
+  }
+}
+
+
diff --git a/time.cpp b/time.cpp
new file mode 100644 (file)
index 0000000..4f36165
--- /dev/null
+++ b/time.cpp
@@ -0,0 +1,142 @@
+#include "Clock.h"
+#include <time.h>
+#include <coredecls.h>
+
+bool isTimeSet = false;
+time_t now;
+time_t last_sync;
+
+int hh;
+int mi;
+int ss;
+
+int dw;
+
+int dd;
+int mm;
+int yy;
+
+#define maxTimeHandlers 8
+const char* tHandlerNames[maxTimeHandlers];
+char tHandlerTypes[maxTimeHandlers];
+std::function<void()> tTimeHandlers[maxTimeHandlers];
+
+void timeIsSet(bool ntp) {
+  if (ntp) {
+    Serial.println(F("Время синхронизировано"));
+    message(F("Время синхронизировано"));
+    reportMessage(F("Время синхронизировано"));
+    if (isRTCEnabled) {
+      RTC.adjust(DateTime(now));
+    }
+  }
+  isTimeSet = true;
+  last_sync = now;
+}
+
+void setupHandlers() {
+  for (int i=0; i<maxTimeHandlers; i++) {
+    tHandlerNames[i] = nullptr;
+    tHandlerTypes[i] = ' ';
+    tTimeHandlers[i] = nullptr;
+  }
+}
+
+void setupTime() {
+  configTime(cfg.getCharValue(F("tz")),cfg.getCharValue(F("ntp_server")));
+  settimeofday_cb(&timeIsSet);
+}
+
+void registerTimeHandler(const char* handlerName, const char handlerType, std::function<void()> timeHandler) {
+  for (int i=0; i<maxTimeHandlers; i++) {
+    if (!tHandlerNames[i]) {
+      // empty slot found!
+      tHandlerNames[i] = handlerName;
+      tHandlerTypes[i] = handlerType;
+      tTimeHandlers[i] = timeHandler;
+      break;
+    }
+  }
+}
+
+void registerTimeHandler(const __FlashStringHelper* handlerName, const char handlerType, std::function<void()> timeHandler) {
+  for (int i=0; i<maxTimeHandlers; i++) {
+    if (!tHandlerNames[i]) {
+      // empty slot found!
+      tHandlerNames[i] = copystr(handlerName);
+      tHandlerTypes[i] = handlerType;
+      tTimeHandlers[i] = timeHandler;
+      break;
+    }
+  }
+}
+
+void unregisterTimeHandler(const char* handlerName) {
+  for (int i=0; i<maxTimeHandlers; i++) {
+    if (tHandlerNames[i] && strcmp(tHandlerNames[i],handlerName) == 0) {
+      tHandlerNames[i] = nullptr;
+      tHandlerTypes[i] = ' ';
+      tTimeHandlers[i] = nullptr;
+      break;
+    }
+  }
+}
+
+void unregisterTimeHandler(const __FlashStringHelper* handlerName) {
+  for (int i=0; i<maxTimeHandlers; i++) {
+    if (tHandlerNames[i] && strcmp_P(tHandlerNames[i],(PGM_P)handlerName) == 0) {
+      tHandlerNames[i] = nullptr;
+      tHandlerTypes[i] = ' ';
+      tTimeHandlers[i] = nullptr;
+      break;
+    }
+  }
+}
+
+void runHandlers(char handlerType) {
+  for (int i=0; i<maxTimeHandlers; i++) {
+    if (tTimeHandlers[i] && tHandlerTypes[i]==handlerType) {
+      tTimeHandlers[i]();
+    }
+  }
+}
+
+void tickTime() {
+  static int prevD = 0, prevH = 0, prevM = 0, prevS = 0;
+  time(&now);
+  struct tm* timeinfo = localtime(&now);  
+  hh = timeinfo->tm_hour;
+  mi = timeinfo->tm_min;
+  ss = timeinfo->tm_sec;
+  dw = timeinfo->tm_wday;
+  dd = timeinfo->tm_mday;
+  mm = timeinfo->tm_mon+1;
+  yy = timeinfo->tm_year+1900;
+  if (dd != prevD) {
+    prevD = dd;
+    runHandlers('d');
+  }
+  if (hh != prevH) {
+    prevH = hh;
+    runHandlers('h');
+  }
+  if (mi != prevM) {
+    prevM = mi;
+    runHandlers('m');
+  }
+  if (ss != prevS) {
+    prevS = ss;
+    runHandlers('s');
+  }
+}
+
+bool isNight() {
+  int day_from = cfg.getIntValue(F("day_from"));
+  int night_from = cfg.getIntValue(F("night_from"));
+  if (day_from<night_from) { // night ... day ... night
+     return hh<day_from || hh>=night_from;
+  } else { /// late day ... night .... day till mindnight
+     return (hh>=night_from && hh<day_from);
+  }
+}
+
diff --git a/ui.yml b/ui.yml
new file mode 100644 (file)
index 0000000..ec261c2
--- /dev/null
+++ b/ui.yml
@@ -0,0 +1,496 @@
+project:
+  name: WiFi Clock
+  version: 0.2.0
+  contacts:
+    - mailto:rvb@rvb.name
+    - tg:rvbglas
+    - https://git.rvb.name
+    - xmpp:rvb@rvb.name
+pages:
+  - id: main
+    title: "Главная"
+    icon: "&#61461;"
+    elements:
+      - type: table
+        label: Имя устройства
+        value: WiFi Clock
+      - type: hr
+      - id: _weather
+        type: table
+        label: Погода
+        color: lightblue
+      - type: hr
+      - id: _time
+        type: table
+        label: Время
+        color: green
+      - id: _date
+        type: table
+        label: Дата
+        color: green
+      - id: _last_sync
+        type: table
+        label: Синхронизировано
+      - type: hr
+      - id: _uptime
+        type: table
+        label: Uptime
+      - id: _mac
+        type: table
+        label: MAC-адрес
+      - id: _rssi
+        type: table
+        label: Уровень сигнала
+      - id: _heap
+        type: table
+        label: Свободная память
+  - id: clock
+    title: Часы
+    icon: "&#61463;"
+    elements:
+      - type: text
+        value: Внешний вид
+      - id: panel_font
+        label: Шрифт часов
+        type: select
+        options:
+          - value: 0
+            text: Стандартный
+          - value: 1
+            text: Высокий
+          - value: 2
+            text: Узкий
+          - value: 3
+            text: Жирный
+          - value: 4
+            text: Жирный прямой
+          - value: 5
+            text: Синклер
+      - id: panel_seconds
+        label: Секунды
+        type: checkbox
+      - id: panel_zero
+        label: Ведущий ноль
+        type: checkbox 
+      - id: flash_dots
+        label: Мигать точками
+        type: checkbox
+      - id: panel_speed
+        label: Скорость        
+        type: range
+        min: 1
+        max: 20
+        step: 1
+      - type: hr
+      - type: text
+        value: Яркость
+      - id: day_from
+        label: Дневной режим с
+        type: number
+        min: 0
+        max: 24
+        step: 1
+      - id: night_from
+        label: Ночной режим с
+        type: number
+        min: 0
+        max: 24
+        step: 1
+      - id: panel_brightness_day
+        label: Яркость днем
+        type: range
+        min: 0
+        max: 14
+        step: 1
+      - id: panel_brightness_night
+        label: Яркость ночью
+        type: range
+        min: 0
+        max: 14
+        step: 1
+  - id: alarm
+    title: Будильник
+    icon: "&#61683;"
+    elements:
+      - type: text
+        value: Будильник
+      - id: enable_alarm
+        type: checkbox
+        label: Включить
+      - id: alarm_hour
+        label: Час
+        type: number
+        min: 0
+        max: 23
+        step: 1
+      - id: alarm_minute
+        label: Минуты
+        type: number
+        min: 0
+        max: 59
+        step: 1
+      - id: alarm_days
+        label: Дни
+        type: week
+      - type: hr
+      - id: alarm_length
+        label: Длительность сигнала, с
+        type: number
+        min: 5
+        max: 120
+        step: 5
+      - id: alarm_tone
+        label: Тон сигнала
+        type: number
+        min: 100
+        max: 5000
+      - id: alarm_beep_ms
+        label: Длительность гудка, мс
+        type: number
+        min: 100
+        max: 2000
+      - id: alarm_silent_ms
+        label: Длительность паузы, мс
+        type: number
+        min: 100
+        max: 2000
+      - type: hr
+      - type: text
+        value: Сигнал каждый час
+      - id: enable_hourly
+        type: checkbox
+        label: Включить
+      - id: hourly_night
+        label: В том числе ночью
+        type: checkbox
+      - id: hourly_count
+        label: Число сигналов
+        type: number
+        min: 1
+        max: 5
+      - id: hourly_tone
+        label: Тон сигнала
+        type: number
+        min: 100
+        max: 5000
+      - id: hourly_beep_ms
+        label: Длительность гудка, мс
+        type: number
+        min: 100
+        max: 2000
+      - id: hourly_silent_ms
+        label: Длительность паузы, мс
+        type: number
+        min: 100
+        max: 2000
+  - id: hw
+    title: Оборудование
+    icon: "&#63449;"
+    elements:
+      - type: text
+        value: Часы реального времени
+      - id: enable_rtc
+        label: Использовать
+        type: checkbox
+      - id: pin_sda
+        label: Пин SDA
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: pin_scl
+        label: Пин SCL
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: i2c_speed
+        label: Скорость шины I2C
+        type: select
+        options:
+          - value: 100000
+            text: 100000 (стандартная)
+          - value: 400000
+            text: 400000 (быстрая)
+      - type: hr
+      - type: text
+        value: Кнопка
+      - id: enable_button
+        label: Использовать
+        type: checkbox
+      - id: button_pin
+        label: Пин кнопки
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: button_inversed
+        label: Кнопка на размыкание
+        type: checkbox
+      - type: hr
+      - type: text
+        value: Зуммер
+      - id: enable_buzzer
+        label: Использовать
+        type: checkbox
+      - id: buzzer_pin
+        label: Пин зуммера
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: buzzer_passive
+        label: Пассивный зуммер
+        type: checkbox
+      - type: hr 
+      - type: text
+        value: Светодиодная панель
+      - id: pin_din
+        label: Пин DIN
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: pin_clk
+        label: Пин CLK
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: pin_cs
+        label: Пин CS
+        type: select
+        options:
+          - value: 16
+            text: D0
+          - value: 5
+            text: D1
+          - value: 4
+            text: D2
+          - value: 0
+            text: D3
+          - value: 2
+            text: D4
+          - value: 14
+            text: D5
+          - value: 12
+            text: D6
+          - value: 13
+            text: D7
+          - value: 15
+            text: D8
+          - value: 3
+            text: "RX*"
+          - value: 1
+            text: "TX*"
+      - id: led_modules
+        label: Число модулей 8*8
+        type: number
+        min: 4
+        max: 8
+  - id: weather
+    title: Погода
+    icon: "&#61673;"
+    elements:
+      - id: enable_weather
+        label: Использовать погодный сервис
+        type: checkbox
+      - id: weather_url
+        label: URL погодного сервиса
+        type: input
+      - id: weather_template
+        label: Шаблон вывода
+        type: input
+      - id: weather_min
+        label: Частота обновления, мин
+        type: number
+        min: 5
+  - id: wifi
+    title: WiFi
+    icon: "&#61931;"
+    elements:
+      - type: text
+        value: Клиент сети
+      - id: sta_ssid
+        type: input-wifi
+        label: 🗘Имя беспроводной сети
+        pattern: ^[^!#;+\]/"\t][^+\]/"\t]{0,31}$
+      - id: sta_psk
+        type: password
+        label: 🗘Ключевая фраза
+      - id: sta_wait
+        type: input
+        label: Ожидать подключения не дольше, с
+      - type: hr
+      - type: text
+        value: Точка доступа
+      - id: ap_ssid
+        type: input
+        label: 🗘Имя беспроводной сети
+        pattern: ^[^!#;+\]/"\t][^+\]/"\t]{0,31}$
+      - id: ap_psk
+        type: password
+        label: 🗘Ключевая фраза
+      - id: wifi
+        type: button
+        label: Применить настройки WiFi
+  - id: system
+    title: Система
+    icon: "&#61573;"
+    elements:
+      - type: text
+        value: Авторизация
+      - id: auth_user
+        type: input
+        label: Логин
+        pattern: ^[A-Za-z0-9]{3,16}$
+      - id: auth_pwd
+        label: Пароль
+        type: password
+        pattern: ^.{4,16}$
+      - id: auth
+        type: button
+        label: Сменить пароль
+      - type: hr
+      - type: text
+        value: Конфигурация
+      - id: _config
+        type: config
+        label: Сохранение и восстановление настроек
+      - type: hr
+      - type: text
+        value: Синхронизация времени
+      - id: _timeset
+        label: Установить время вручную
+        type: timeset
+      - id: ntp_server
+        type: input
+        label: NTP-сервер
+        pattern: ^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$
+      - id: tz
+        type: input
+        label: Часовой пояс (POSIX)
+        pattern: ^([A-Za-z]*)(-?[0-9]*(:[0-9]*)?)((([A-Za-z]*)(-?[0-9]*)?)?(,M[0-9]*\.[0-9]*\.[0-9]*/([0-9]*:?){3}){2})?$
+      - id: restart
+        type: button
+        label: Перезагрузить
diff --git a/weather.cpp b/weather.cpp
new file mode 100644 (file)
index 0000000..dc11706
--- /dev/null
@@ -0,0 +1,147 @@
+#include "Clock.h"
+#include "AsyncHTTPRequest_Generic.h"
+#include <Ticker.h>
+#include <ArduinoJson.h>
+
+Ticker* weatherTicker = nullptr;
+AsyncHTTPRequest request;
+Config weather;
+char weatherData[256];
+
+void executeWeatherRequest();
+
+void IterateJson(JsonVariant json, const String& rootName, Config& lCfg);
+
+void IterateJsonObject(JsonObject json, String rootName, Config& lCfg) {
+  for (JsonPair kv : json) {
+    IterateJson(kv.value(), rootName == "" ? String(kv.key().c_str()) : rootName + "." + String(kv.key().c_str()),lCfg);
+  }
+}
+
+void IterateJsonArray(JsonArray json, const String& rootName,  Config& lCfg) {
+  for (int i = 0; i < json.size(); i++) {
+    IterateJson(json[i], rootName == "" ? "[" + String(i) + "]" : rootName + "." + "[" + String(i) + "]", lCfg);
+  }
+}
+
+void IterateJson(JsonVariant json, const String& rootName, Config& lCfg) {
+  if (json.is<JsonObject>()) {
+    IterateJsonObject(json.as<JsonObject>(), rootName, lCfg);
+  } else if (json.is<JsonArray>()) {
+    IterateJsonArray(json.as<JsonArray>(), rootName, lCfg);
+  } else {
+    lCfg.setValue(rootName.c_str(), json.as<String>().c_str());
+  }
+}
+
+void processTemplates(char* buf, const char* strTemplate, const Config& cfg, int maxLen) {
+  buf[0] = 0;
+  char varName[34];
+  const char* ptr = strTemplate;
+  while (true) {
+    char* pos = strchr(ptr, '%');
+    if (!pos) {
+      strncat(buf, ptr, maxLen);
+      break;
+    }
+    int len = pos - ptr;
+    if (len + strlen(buf) >= maxLen) {
+      len = maxLen - strlen(buf);
+    }
+    int bufLen = strlen(buf);
+    strncpy(buf + bufLen, ptr, len);
+    buf[bufLen+len] = 0;
+    char* endpos = strchr(pos+1, '%');
+    if (!endpos) {
+      Serial.println(F("Незавершенное имя переменной"));
+      break;
+    }
+    int varLen = (endpos-1) - (pos);
+    if (varLen>64) {
+      Serial.println(F("Имя переменной слишком длинное"));
+      break;
+    }
+    if (varLen) {
+      strncpy(varName,pos+1,varLen);
+      varName[varLen] = 0;
+      const char* value = cfg.getCharValue(varName);
+      if (value) {
+        strncat(buf, value, maxLen);
+      } else {
+        strncat(buf, "??", maxLen);
+      }
+    } else {
+      strncat(buf, "%", maxLen);
+    }  
+    ptr = endpos+1;
+  }
+}
+
+void requestCB(void* optParm, AsyncHTTPRequest* request, int readyState) {
+  const char* weather_template = cfg.getCharValue(F("weather_template"));
+
+  if (readyState == readyStateDone) {
+    if (request->responseHTTPcode() == 200) {
+      String weather_json = request->responseText();
+      DynamicJsonDocument* current_weather = new DynamicJsonDocument(2048);
+      DeserializationError error = deserializeJson(*current_weather, weather_json);
+      if (error) {
+        Serial.print(F("Ошибка разбора ответа: "));
+        Serial.println(error.c_str());
+        Serial.println(weather_json);
+        reportMessage(F("Ошибка обновления погоды"));
+        return;
+      }
+      weather.clear();
+      IterateJson(current_weather->as<JsonObject>(), "", weather);
+      weather_json = String();
+      delete current_weather;
+      processTemplates(weatherData,weather_template,weather,255);
+      sendWeather();
+      reportMessage(F("Погода обновлена"));
+      scroll(weatherData, !isNight());
+    }
+  }
+}
+
+void weatherRequest() {
+  if (!WiFi.isConnected()) { return; }
+  if (WiFi.isConnected() && weatherTicker) {
+    weatherTicker->detach();
+    int weather_min = cfg.getIntValue(F("weather_min"));
+    weather_min = weather_min ? weather_min : 60;
+    weatherTicker->attach(weather_min * 60, executeWeatherRequest);  // reschedule to requested interval
+  }
+  static bool requestOpenResult;
+  if (request.readyState() == readyStateUnsent || request.readyState() == readyStateDone) {
+    const char* weather_url = cfg.getCharValue(F("weather_url"));
+    requestOpenResult = request.open("GET", weather_url);
+
+    if (requestOpenResult) {
+      request.send();
+    } else {
+      Serial.println(F("Не удалось отправить запрос"));
+    }
+  } else {
+    Serial.println(F("Не удалось отправить запрос"));
+  }
+}
+
+void executeWeatherRequest() {
+  if (cfg.getBoolValue(F("enable_weather"))) {
+    request.onReadyStateChange(requestCB);
+    weatherRequest();
+  }
+}
+
+void setupWeatherRequest() {
+  if (weatherTicker) {
+    weatherTicker->detach();
+    delete weatherTicker;
+    weatherTicker = nullptr;
+  }
+  if (cfg.getBoolValue(F("enable_weather"))) {
+    weatherTicker = new Ticker;
+    weatherTicker->attach(30, executeWeatherRequest);  // before connection - every 30s
+  }
+}
\ No newline at end of file
diff --git a/web.cpp b/web.cpp
new file mode 100644 (file)
index 0000000..13e7916
--- /dev/null
+++ b/web.cpp
@@ -0,0 +1,424 @@
+#include "Clock.h"
+#include <LittleFS.h>
+#include <ESPAsyncWebServer.h>
+#include <StreamString.h>
+#include <Ticker.h>
+#include "ArduinoJson.h"
+#include "AsyncJson.h"
+
+bool isApEnabled = false;
+bool isWebStarted = false;
+
+bool pendingWiFi = false;
+bool pendingAuth = false;
+
+AsyncWebServer server(80);
+AsyncEventSource events("/events");
+
+Ticker tKeepalive;
+
+char auth_user[32];
+char auth_pwd[32];
+
+void reportChange(const __FlashStringHelper* name) {
+  char buf[256];
+  ConfigParameter* param = cfg.getParam(name);
+  if (param) {
+    switch (param->getType()) {
+      case 'B':
+        sprintf(buf,"{\"%s\":%s}", name, param->getBoolValue()?"true":"false");
+        break;
+      case 'I':
+        sprintf(buf,"{\"%s\":%d}", name, param->getIntValue());
+        break;
+      case 'F':
+        sprintf(buf,"{\"%s\":%f}", name, param->getFloatValue());
+        break;
+      case 'S':
+        sprintf(buf,"{\"%s\":\"%s\"}", name, param->getCharValue());
+        break;
+    }
+    events.send(buf,"update",millis());
+  }
+}
+
+void reportMessage(const __FlashStringHelper* msg) {
+  char buf[256];
+  strcpy_P(buf, (PGM_P)msg);
+  events.send(buf,"message",millis());
+}
+
+void sendInitial(AsyncEventSourceClient *client) {
+  String mac = WiFi.macAddress();
+  char buf[256];
+  sprintf(buf,"{\"_mac\":\"%s\",\"_weather\":\"%s\"}", mac.c_str(), weatherData);
+  client->send(buf,"update",millis());
+  mac = String();
+}
+
+void sendWeather() {
+  char buf[256];
+  sprintf(buf,"{\"_weather\":\"%s\"}",weatherData);
+  events.send(buf,"update",millis());
+}
+
+void sendKeepalive() {
+  static unsigned long lastMillis = 0;
+  static unsigned long uptimeCorrection = 0;
+  unsigned long currentMillis = millis();
+  unsigned long uptime = millis()/1000;
+  if (currentMillis < lastMillis) {
+    uptimeCorrection += lastMillis/1000;
+  }
+  lastMillis = millis();
+  uptime = uptime + uptimeCorrection;
+  int days = uptime / 86400;
+  uptime = uptime % 86400;
+  int hrs = uptime / 3600;
+  uptime = uptime % 3600;
+  int mins = uptime / 60;
+  uptime = uptime % 60;
+  int heap = ESP.getFreeHeap();
+  int rssi = WiFi.RSSI();
+  struct tm* timeinfo = localtime(&last_sync);
+  bool changed = cfg.getTimestamp() != 0;
+  char sync[16] = "--:--:--";
+  if (last_sync) {
+    sprintf(sync, "%02d:%02d:%02d", timeinfo->tm_hour, timeinfo->tm_min, timeinfo->tm_sec);
+  }
+  char buf[256];
+  if (days) {
+    sprintf(buf,"{\"_uptime\":\"%d д %d ч %d % м %d с\", \"_date\":\"%02d.%2d.%04d\", \"_time\":\"%02d:%02d\",\"_heap\":\"%d б\", \"_rssi\":\"%d\",  \"_last_sync\":\"%s\", \"_changed\":%s}", days, hrs, mins, uptime, dd, mm, yy, hh, mi, heap, rssi, sync, changed?"true":"false");
+  } else if (hrs) {
+    sprintf(buf,"{\"_uptime\":\"%d ч %d % м %d с\", \"_date\":\"%02d.%2d.%04d\", \"_time\":\"%02d:%02d\",\"_heap\":\"%d б\", \"_rssi\":\"%d\",  \"_last_sync\":\"%s\", \"_changed\":%s}", hrs, mins, uptime, dd, mm, yy, hh, mi, heap, rssi, sync, changed?"true":"false");
+  } else {
+    sprintf(buf,"{\"_uptime\":\"%d м %d с\", \"_date\":\"%02d.%2d.%04d\", \"_time\":\"%02d:%02d\",\"_heap\":\"%d б\", \"_rssi\":\"%d\",  \"_last_sync\":\"%s\", \"_changed\":%s}", mins, uptime, dd, mm, yy, hh, mi, heap, rssi, sync, changed?"true":"false");
+  }
+  events.send(buf,"update",millis());
+}
+
+void apply(const char* name) {
+  if (strcmp(name,"sta_ssid") == 0 || strcmp(name,"sta_psk") == 0) {
+    if (!isApEnabled) {
+      pendingWiFi = true;
+    }
+  } else if (strcmp(name,"ap_ssid") == 0 || strcmp(name,"ap_psk") == 0) {
+    if (isApEnabled) {
+      pendingWiFi = true;
+    }
+  } else if (strcmp(name,"auth_user") == 0 || strcmp(name,"auth_pwd") == 0) {
+    pendingAuth = true;
+  } else if (strcmp(name,"ntp_server") == 0 || strcmp(name,"tz") == 0) {
+    setupTime();
+  } else if (strcmp(name,"pin_sda") == 0 || strcmp(name,"pin_scl") == 0 ||
+    strcmp(name,"i2c_speed") == 0 || strcmp(name,"enable_rtc") == 0 ||
+    strcmp(name,"enable_button") == 0 || strcmp(name,"button_pin") == 0 || strcmp(name,"button_inversed") == 0 ||
+    strcmp(name,"enable_buzzer") == 0 || strcmp(name,"buzzer_pin") == 0) {
+    setupHardware();
+  } else if (strcmp(name,"pin_din") == 0 || strcmp(name,"pin_clk") == 0 ||
+    strcmp_P(name,"pin_cs") == 0 || strcmp(name,"led_modules") == 0){
+    setupPanel();
+  } else if (strcmp(name,"panel_brightness_day") == 0 || strcmp_P(name,"panel_brightness_night") == 0 ||
+    strcmp_P(name,"day_from") == 0 || strcmp(name,"night_from") == 0){
+    setPanelBrightness();
+  }
+}
+
+char* actionScheduled = nullptr;
+unsigned long millisScheduled = 0;
+
+void setupWeb() {
+  char buf[256];
+
+  if (isWebStarted) {
+    tKeepalive.detach();
+    server.end();
+  }
+
+  isWebStarted = true;
+
+  strncpy(auth_user,cfg.getCharValue(F("auth_user")),31);
+  strncpy(auth_pwd,cfg.getCharValue(F("auth_pwd")),31);
+
+  server.on("/action", HTTP_GET, [](AsyncWebServerRequest* request) {
+    if (auth_user && auth_pwd && auth_user[0] && auth_pwd[0] && !request->authenticate(auth_user, auth_pwd)) {
+      return request-> requestAuthentication();
+    }
+    if(request->hasParam("name")) {
+      const char* action = request->getParam("name")->value().c_str();
+      if (strcmp(action,"reset") == 0) {
+          millisScheduled = millis();
+          actionScheduled = "reset";
+          request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Сбрасываю настройки и перезагружаюсь\"}");
+      } else if (strcmp(action,"restart") == 0) {
+        if (pendingWiFi) {
+          request->send(200,"application/json", "{\"result\":\"FAILED\",\"message\":\"Не применены настройки WiFi\", \"page\":\"wifi\"}");
+        } else if (pendingAuth) {
+          request->send(200,"application/json", "{\"result\":\"FAILED\",\"message\":\"Не применены настройки авторизации\", \"page\":\"system\"}");
+        } else {
+          request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Перезагружаюсь\"}");
+          millisScheduled = millis();
+          actionScheduled = "restart";
+        }
+      } else if (strcmp(action,"wifi") == 0) {
+        if (pendingAuth) {
+          request->send(200,"application/json", "{\"result\":\"FAILED\",\"message\":\"Не применены настройки авторизации\", \"page\":\"system\"}");
+        } else {
+          request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Применяю настройки\"}");
+          millisScheduled = millis();
+          actionScheduled = "wifi";
+        }
+      } else if (strcmp(action,"auth") == 0) {
+        request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Применяю настройки\"}");
+        millisScheduled = millis();
+        actionScheduled = "auth";
+      } else if (strcmp(action,"save") == 0) {
+        if (pendingWiFi) {
+          request->send(200,"application/json", "{\"result\":\"FAILED\",\"message\":\"Не применены настройки WiFi\", \"page\":\"wifi\"}");
+        } else if (pendingAuth) {
+          request->send(200,"application/json", "{\"result\":\"FAILED\",\"message\":\"Не применены настройки авторизации\", \"page\":\"system\"}");
+        } else {
+          request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Сохраняю настройки\"}");
+          millisScheduled = millis();
+          actionScheduled = "save";
+        }
+      } else if (strcmp(action,"time") == 0) {
+        if(request->hasParam("timestamp")) {
+          unsigned long timestamp = atoi(request->getParam("timestamp")->value().c_str());
+          if (timestamp) {
+            timeval tv = { timestamp, 0 };
+            settimeofday(&tv, nullptr);
+            if (isRTCEnabled) {
+              Serial.println(F("Время установлено вручную"));
+              RTC.adjust(DateTime(timestamp));
+            }
+          }
+          request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Устанавливаю время\"}");
+        } else {
+          request->send(500, "text/plain", "{\"result\":\"FAILED\",\"message\":\"Not all parameters set\"}");
+        }
+      } else {
+        request->send(500, "text/plain", "{\"result\":\"FAILED\",\"message\":\"Unsupported action\"}");
+      }
+    } else {
+      request->send(500, "text/plain", "{\"result\":\"FAILED\",\"message\":\"Not all parameters set\"}");
+    }
+  });
+
+  server.on("/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) {
+    if (auth_user && auth_pwd && auth_user[0] && auth_pwd[0] && !request->authenticate(auth_user, auth_pwd)) {
+      return request-> requestAuthentication();
+    }
+    String json = "[";
+    int n = WiFi.scanComplete();
+    if (n == -2) {
+      WiFi.scanNetworks(true);
+    } else if (n) {
+      for (int i = 0; i < n; ++i) {
+        if (i) json += ",";
+        json += "{";
+        json += "\"rssi\":" + String(WiFi.RSSI(i));
+        json += ",\"ssid\":\"" + WiFi.SSID(i) + "\"";
+        json += ",\"bssid\":\"" + WiFi.BSSIDstr(i) + "\"";
+        json += ",\"channel\":" + String(WiFi.channel(i));
+        json += ",\"secure\":" + String(WiFi.encryptionType(i));
+        json += ",\"hidden\":" + String(WiFi.isHidden(i) ? "true" : "false");
+        json += "}";
+      }
+      WiFi.scanDelete();
+      if (WiFi.scanComplete() == -2) {
+        WiFi.scanNetworks(true);
+      }
+    }
+    json += "]";
+    request->send(200, "application/json", json);
+    json = String();
+  });
+
+  server.on("/config/get", HTTP_GET, [](AsyncWebServerRequest* request) {
+    if (auth_user && auth_pwd && auth_user[0] && auth_pwd[0] && !request->authenticate(auth_user, auth_pwd)) {
+      return request-> requestAuthentication();
+    }
+    AsyncResponseStream* s = request->beginResponseStream("application/json");
+    s->print("{");
+    for (int i = 0; i < cfg.getParametersCount(); i++) {
+      ConfigParameter* param = cfg.getParameter(i);
+      if (i) s->print(",");
+      s->print("\"");
+      s->print(param->getID());
+      s->print("\":");
+      switch (param->getType()) {
+        case 'B':
+          s->print(param->getBoolValue() ? "true" : "false");
+          break;
+        case 'I':
+          s->print(param->getIntValue());
+          break;
+        case 'F':
+          s->print(param->getFloatValue());
+          break;
+        case 'S':
+          s->print("\"");
+          s->print(param->getCharValue());
+          s->print("\"");
+          break;
+      }
+    }
+    s->print("}");
+    request->send(s);
+  });
+
+  server.on("/config/set", HTTP_GET, [](AsyncWebServerRequest* request) {
+    if (auth_user && auth_pwd && auth_user[0] && auth_pwd[0] && !request->authenticate(auth_user, auth_pwd)) {
+      return request-> requestAuthentication();
+    }
+    if(request->hasParam("name") && (request->hasParam("value"))) {
+      const char* name = request->getParam("name")->value().c_str();
+      const char* value = request->getParam("value")->value().c_str();
+      Serial.print(name); Serial.print(" = "); Serial.println(value);
+      char buf[256];
+      ConfigParameter* param = cfg.getParam(name);
+      if (param) {
+        switch (param->getType()) {
+          case 'B':
+            cfg.setValue(name, strcmp(value, "true")==0);
+            sprintf(buf,"{\"%s\":%s}", name, param->getBoolValue()?"true":"false");
+            break;
+          case 'I':
+            cfg.setValue(name, atoi(value));
+            sprintf(buf,"{\"%s\":%d}", name, param->getIntValue());
+            break;
+          case 'F':
+            cfg.setValue(name, atof(value));
+            sprintf(buf,"{\"%s\":%f}", name, param->getFloatValue());
+            break;
+          case 'S':
+            cfg.setValue(name, value);
+            sprintf(buf,"{\"%s\":\"%s\"}", name, param->getCharValue());
+            break;
+        }
+        apply(name);
+      } else {
+          request->send(500, "text/plain", "Unknown parameter name");
+          return;
+      }
+      request->send(200,"application/json",buf);
+     } else {
+      request->send(500, "text/plain", "Not all parameters set");
+    }
+  });
+
+  AsyncCallbackJsonWebHandler* configUploadHandler = new AsyncCallbackJsonWebHandler("/config/put", [](AsyncWebServerRequest *request, JsonVariant &json) {
+    cfg.clear();
+    // first - set values
+    for( JsonPair kv : json.as<JsonObject>() ) {
+      const char* name = kv.key().c_str();
+      if (kv.value().is<bool>()) {
+        cfg.setValue(name, kv.value().as<bool>());
+      } else if (kv.value().is<int>()) {
+        cfg.setValue(name, kv.value().as<int>());
+      } else if (kv.value().is<double>()) {
+        cfg.setValue(name, kv.value().as<double>());
+      } else if (kv.value().is<char*>()) {
+        cfg.setValue(name, kv.value().as<char*>());
+      } else {
+        Serial.print(F("Неопознанный тип значения параметра ")); Serial.print(name); Serial.print(": "); Serial.println(kv.value().as<String>().c_str());
+        cfg.clear();
+        setupConfig();
+        request->send(500, "text/plain", "Unknown parameter type");
+      }
+    }
+    // second - handle all changes
+    for( JsonPair kv : json.as<JsonObject>() ) {
+      apply(kv.key().c_str());
+    }
+    pendingWiFi = false;
+    pendingAuth = false;
+    saveConfig();
+    message(F("Применены сохраненные настройки"),5);
+    reportMessage(F("Применены сохраненные настройки"));
+    millisScheduled = millis() + 10000;
+    actionScheduled = "restart";
+    request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Настройки восстановлены из резервной копии\"}");
+  });
+
+  server.addHandler(configUploadHandler).setAuthentication(auth_user,auth_pwd);
+
+  server.serveStatic("ui", LittleFS, "/ui.json").setAuthentication(auth_user,auth_pwd);
+
+  server.serveStatic("/", LittleFS, "/web/").setDefaultFile("index.html").setAuthentication(auth_user,auth_pwd);
+
+  server.onNotFound([](AsyncWebServerRequest *request){
+    request->send(404,"text/plain","Not found");
+  });
+
+  events.onConnect([](AsyncEventSourceClient *client){
+    sendInitial(client);
+  });
+
+  events.setAuthentication(auth_user,auth_pwd);
+
+  server.addHandler(&events);
+
+  server.begin();
+
+  tKeepalive.attach(2, sendKeepalive);
+
+}
+
+#define CFG_AUTOSAVE 15
+
+void tickWeb() {
+  static char storedSSID[64];
+  static char storedPSK[64];
+  static bool connectInProgress = false;
+  static unsigned long connectMillis = 0;
+  if (actionScheduled && millis()>millisScheduled+300) {
+    Serial.print(F("Запланированная операция ")); Serial.println(actionScheduled);
+    //
+    if (strcmp(actionScheduled,"reset") == 0) {
+      server.end();
+      reset();
+    } else if (strcmp(actionScheduled,"restart") == 0) {
+      server.end();
+      reboot();
+    } else if (strcmp(actionScheduled,"auth") == 0) {
+      Serial.println("Логин/пароль изменены");
+      strncpy(auth_user,cfg.getCharValue(F("auth_user")),31);
+      strncpy(auth_pwd,cfg.getCharValue(F("auth_pwd")),31);
+      pendingAuth = false;
+    } else if (strcmp(actionScheduled,"wifi") == 0) {
+      Serial.println("Применяю настройки сети");
+      strcpy(storedSSID,WiFi.SSID().c_str());
+      strcpy(storedPSK,WiFi.psk().c_str());
+      WiFi.mode(WIFI_STA);
+      WiFi.begin(cfg.getCharValue("sta_ssid"),cfg.getCharValue("sta_psk"));
+      connectInProgress = true;
+      connectMillis = millis();
+    } else if (strcmp(actionScheduled,"save") == 0 && !pendingWiFi && !pendingAuth) {
+      saveConfig();
+    }
+    actionScheduled = nullptr;
+  }
+  if (connectInProgress && (millis() > connectMillis + 1000)) {
+    if (WiFi.status() == WL_CONNECTED) {
+      char buf[64];
+      sprintf(buf,"Подключен к %s, IP=%s", WiFi.SSID(), WiFi.localIP().toString().c_str());
+      Serial.println(buf);
+      message(buf,1);
+      pendingWiFi = false;
+      connectInProgress = false;
+    } else if (WiFi.status() == WL_CONNECT_FAILED || WiFi.status() == WL_NO_SSID_AVAIL || ((WiFi.status() == WL_CONNECTION_LOST || WiFi.status() == WL_DISCONNECTED) && (millis()>connectMillis+1000*cfg.getIntValue("sta_wait")))) {
+      Serial.println(F("Подключение не удалось, возвращаю прежние настройки"));
+      message(F("Подключение не удалось, возвращаю прежние настройки"),1);
+      WiFi.begin(storedSSID, storedPSK);
+      connectInProgress = false;
+    }
+  }
+
+  if (!pendingWiFi && !pendingAuth && cfg.getTimestamp() && cfg.getTimestamp() < now - CFG_AUTOSAVE) {
+    saveConfig();
+    reportMessage(F("Настройки сохранены"));
+    Serial.println(F("Настройки сохранены"));
+  }
+}