From 5f927ad8c51b1e57aa4419a2d0d0bb66ae0ded5f Mon Sep 17 00:00:00 2001 From: lumapu Date: Sun, 17 Apr 2022 01:54:48 +0200 Subject: [PATCH] added ESP8266 code --- tools/esp8266/CircularBuffer.h | 158 ++++++++++++++ tools/esp8266/README.md | 45 ++++ tools/esp8266/ahoy-esp.ino | 25 +++ tools/esp8266/app.cpp | 265 +++++++++++++++++++++++ tools/esp8266/app.h | 50 +++++ tools/esp8266/defines.h | 37 ++++ tools/esp8266/eep.cpp | 130 ++++++++++++ tools/esp8266/eep.h | 30 +++ tools/esp8266/hoymiles.h | 228 ++++++++++++++++++++ tools/esp8266/html/conv.bat | 4 + tools/esp8266/html/h/index_html.h | 1 + tools/esp8266/html/h/setup_html.h | 1 + tools/esp8266/html/h/style_css.h | 1 + tools/esp8266/html/index.html | 45 ++++ tools/esp8266/html/setup.html | 51 +++++ tools/esp8266/html/style.css | 151 +++++++++++++ tools/esp8266/main.cpp | 340 ++++++++++++++++++++++++++++++ tools/esp8266/main.h | 79 +++++++ tools/esp8266/tools/fileConv.exe | Bin 0 -> 67584 bytes 19 files changed, 1641 insertions(+) create mode 100644 tools/esp8266/CircularBuffer.h create mode 100644 tools/esp8266/README.md create mode 100644 tools/esp8266/ahoy-esp.ino create mode 100644 tools/esp8266/app.cpp create mode 100644 tools/esp8266/app.h create mode 100644 tools/esp8266/defines.h create mode 100644 tools/esp8266/eep.cpp create mode 100644 tools/esp8266/eep.h create mode 100644 tools/esp8266/hoymiles.h create mode 100644 tools/esp8266/html/conv.bat create mode 100644 tools/esp8266/html/h/index_html.h create mode 100644 tools/esp8266/html/h/setup_html.h create mode 100644 tools/esp8266/html/h/style_css.h create mode 100644 tools/esp8266/html/index.html create mode 100644 tools/esp8266/html/setup.html create mode 100644 tools/esp8266/html/style.css create mode 100644 tools/esp8266/main.cpp create mode 100644 tools/esp8266/main.h create mode 100644 tools/esp8266/tools/fileConv.exe diff --git a/tools/esp8266/CircularBuffer.h b/tools/esp8266/CircularBuffer.h new file mode 100644 index 000000000..ab29e96a7 --- /dev/null +++ b/tools/esp8266/CircularBuffer.h @@ -0,0 +1,158 @@ +/* + CircularBuffer - An Arduino circular buffering library for arbitrary types. + + Created by Ivo Pullens, Emmission, 2014 -- www.emmission.nl + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef CircularBuffer_h +#define CircularBuffer_h + +#ifdef ESP8266 +#define DISABLE_IRQ noInterrupts() +#define RESTORE_IRQ interrupts() +#else +#define DISABLE_IRQ \ + uint8_t sreg = SREG; \ + cli(); + +#define RESTORE_IRQ \ + SREG = sreg; +#endif + +template class CircularBuffer +{ + public: + /** Constructor + * @param buffer Preallocated buffer of at least size records. + * @param size Number of records available in the buffer. + */ + CircularBuffer(T* buffer, const uint8_t size ) + : m_size(size), m_buff(buffer) + { + clear(); + } + + /** Clear all entries in the circular buffer. */ + void clear(void) + { + m_front = 0; + m_fill = 0; + } + + /** Test if the circular buffer is empty */ + inline bool empty(void) const + { + return !m_fill; + } + + /** Return the number of records stored in the buffer */ + inline uint8_t available(void) const + { + return m_fill; + } + + /** Test if the circular buffer is full */ + inline bool full(void) const + { + return m_fill == m_size; + } + + /** Aquire record on front of the buffer, for writing. + * After filling the record, it has to be pushed to actually + * add it to the buffer. + * @return Pointer to record, or NULL when buffer is full. + */ + T* getFront(void) const + { + DISABLE_IRQ; + T* f = NULL; + if (!full()) + f = get(m_front); + RESTORE_IRQ; + return f; + } + + /** Push record to front of the buffer + * @param record Record to push. If record was aquired previously (using getFront) its + * data will not be copied as it is already present in the buffer. + * @return True, when record was pushed successfully. + */ + bool pushFront(T* record) + { + bool ok = false; + DISABLE_IRQ; + if (!full()) + { + T* f = get(m_front); + if (f != record) + *f = *record; + m_front = (m_front+1) % m_size; + m_fill++; + ok = true; + } + RESTORE_IRQ; + return ok; + } + + /** Aquire record on back of the buffer, for reading. + * After reading the record, it has to be pop'ed to actually + * remove it from the buffer. + * @return Pointer to record, or NULL when buffer is empty. + */ + T* getBack(void) const + { + T* b = NULL; + DISABLE_IRQ; + if (!empty()) + b = get(back()); + RESTORE_IRQ; + return b; + } + + /** Remove record from back of the buffer. + * @return True, when record was pop'ed successfully. + */ + bool popBack(void) + { + bool ok = false; + DISABLE_IRQ; + if (!empty()) + { + m_fill--; + ok = true; + } + RESTORE_IRQ; + return ok; + } + + protected: + inline T * get(const uint8_t idx) const + { + return &(m_buff[idx]); + } + inline uint8_t back(void) const + { + return (m_front - m_fill + m_size) % m_size; + } + + const uint8_t m_size; // Total number of records that can be stored in the buffer. + T* const m_buff; // Ptr to buffer holding all records. + volatile uint8_t m_front; // Index of front element (not pushed yet). + volatile uint8_t m_fill; // Amount of records currently pushed. +}; + +#endif // CircularBuffer_h diff --git a/tools/esp8266/README.md b/tools/esp8266/README.md new file mode 100644 index 000000000..f8690dec4 --- /dev/null +++ b/tools/esp8266/README.md @@ -0,0 +1,45 @@ +## OVERVIEW + +This code was tested on a ESP8266 - ESP-07 module. Many parts of the code are based on 'Hubi's code, which can be found here: + +The NRF24L01+ radio module is connected to the standard SPI pins. Additional there are 3 pins, which can be set individual: + +- IRQ - Pin 4 +- CE - Pin 5 +- CS - Pin 15 + + +## Compile + +This code can be compiled using Arduino. The settings were: + +- Board: Generic ESP8266 Module +- Flash-Size: 1MB (FS: none, OTA: 502kB) + + +## Flash ESP with firmware + +1. flash the ESP with the compiled firmware using the UART pins or any preinstalled firmware with OTA capabilities +2. repower the ESP +3. the ESP will start as access point (AP) if there is no network config stored in its eeprom +4. connect to the AP, you will be forwarded to the setup page +5. configure your WiFi settings, save, repower +6. check your router for the IP address of the module + + +## Usage + +Connect the ESP to power and to your serial console. The webinterface is currently only used for OTA and config. +The serial console will print all information which is send and received. + + +## Known Issues + +- only command 0x81 is received + + +## USED LIBRARIES + +- `Time` +- `RF24` + diff --git a/tools/esp8266/ahoy-esp.ino b/tools/esp8266/ahoy-esp.ino new file mode 100644 index 000000000..2dcc84c3a --- /dev/null +++ b/tools/esp8266/ahoy-esp.ino @@ -0,0 +1,25 @@ +#include "app.h" + +app myApp; + +//----------------------------------------------------------------------------- +void setup() { + pinMode(RF24_IRQ_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(RF24_IRQ_PIN), handleIntr, FALLING); + + // AP name, password, timeout + myApp.setup("ESP AHOY", "esp_8266", 15); +} + + +//----------------------------------------------------------------------------- +void loop() { + myApp.loop(); +} + + +//----------------------------------------------------------------------------- +ICACHE_RAM_ATTR void handleIntr(void) { + myApp.handleIntr(); +} + diff --git a/tools/esp8266/app.cpp b/tools/esp8266/app.cpp new file mode 100644 index 000000000..6e0d45fa9 --- /dev/null +++ b/tools/esp8266/app.cpp @@ -0,0 +1,265 @@ +#include "app.h" + +#include "html/h/index_html.h" +extern String setup_html; + +//----------------------------------------------------------------------------- +app::app() : Main() { + uint8_t wrAddr[6]; + mRadio = new RF24(RF24_CE_PIN, RF24_CS_PIN); + + mRadio->begin(); + mRadio->setAutoAck(false); + mRadio->setRetries(0, 0); + + mHoymiles = new hoymiles(); + mEep->read(ADDR_HOY_ADDR, mHoymiles->mAddrBytes, HOY_ADDR_LEN); + mHoymiles->serial2RadioId(); + + mBufCtrl = new CircularBuffer(mBuffer, PACKET_BUFFER_SIZE); + + mSendCnt = 0; + mSendTicker = new Ticker(); +} + + +//----------------------------------------------------------------------------- +app::~app(void) { + +} + + +//----------------------------------------------------------------------------- +void app::setup(const char *ssid, const char *pwd, uint32_t timeout) { + Main::setup(ssid, pwd, timeout); + + mWeb->on("/", std::bind(&app::showIndex, this)); + mWeb->on("/setup", std::bind(&app::showSetup, this)); + mWeb->on("/save ", std::bind(&app::showSave, this)); + + initRadio(); + + mSendTicker->attach_ms(1000, std::bind(&app::sendTicker, this)); +} + + +//----------------------------------------------------------------------------- +void app::loop(void) { + Main::loop(); + + if(!mBufCtrl->empty()) { + uint8_t len, rptCnt; + NRF24_packet_t *p = mBufCtrl->getBack(); + + //mHoymiles->dumpBuf("RAW", p->packet, PACKET_BUFFER_SIZE); + + if(mHoymiles->checkCrc(p->packet, &len, &rptCnt)) { + // process buffer only on first occurrence + if((0 != len) && (0 == rptCnt)) { + mHoymiles->dumpBuf("Payload", p->packet, len); + // @TODO: do analysis here + } + } + else { + if(p->packetsLost != 0) { + Serial.println("Lost packets: " + String(p->packetsLost)); + } + } + mBufCtrl->popBack(); + } +} + + +//----------------------------------------------------------------------------- +void app::handleIntr(void) { + uint8_t lostCnt = 0, pipe, len; + NRF24_packet_t *p; + + DISABLE_IRQ; + + while(mRadio->available(&pipe)) { + if(!mBufCtrl->full()) { + p = mBufCtrl->getFront(); + memset(p->packet, 0xcc, MAX_RF_PAYLOAD_SIZE); + p->timestamp = micros(); // Micros does not increase in interrupt, but it can be used. + p->packetsLost = lostCnt; + len = mRadio->getPayloadSize(); + if(len > MAX_RF_PAYLOAD_SIZE) + len = MAX_RF_PAYLOAD_SIZE; + + mRadio->read(p->packet, len); + mBufCtrl->pushFront(p); + lostCnt = 0; + } + else { + bool tx_ok, tx_fail, rx_ready; + if(lostCnt < 255) + lostCnt++; + mRadio->whatHappened(tx_ok, tx_fail, rx_ready); // reset interrupt status + mRadio->flush_rx(); // drop the packet + } + } + + RESTORE_IRQ; +} + + +//----------------------------------------------------------------------------- +void app::initRadio(void) { + mRadio->setChannel(DEFAULT_RECV_CHANNEL); + mRadio->setDataRate(RF24_250KBPS); + mRadio->disableCRC(); + mRadio->setAutoAck(false); + mRadio->setPayloadSize(MAX_RF_PAYLOAD_SIZE); + mRadio->setAddressWidth(5); + mRadio->openReadingPipe(1, DTU_RADIO_ID); + + // enable only receiving interrupts + mRadio->maskIRQ(true, true, false); + + // Use lo PA level, as a higher level will disturb CH340 serial usb adapter + mRadio->setPALevel(RF24_PA_MAX); + mRadio->startListening(); + + Serial.println("Radio Config:"); + mRadio->printPrettyDetails(); +} + + +//----------------------------------------------------------------------------- +void app::sendPacket(uint8_t buf[], uint8_t len) { + DISABLE_IRQ; + + mRadio->stopListening(); + +#ifdef CHANNEL_HOP + mRadio->setChannel(mHoymiles->getNxtChannel()); +#else + mRadio->setChannel(mHoymiles->getDefaultChannel()); +#endif + + mRadio->openWritingPipe(mHoymiles->mRadioId); + mRadio->setCRCLength(RF24_CRC_16); + mRadio->enableDynamicPayloads(); + mRadio->setAutoAck(true); + mRadio->setRetries(3, 15); + + mRadio->write(buf, len); + + // Try to avoid zero payload acks (has no effect) + mRadio->openWritingPipe(DUMMY_RADIO_ID); + + mRadio->setAutoAck(false); + mRadio->setRetries(0, 0); + mRadio->disableDynamicPayloads(); + mRadio->setCRCLength(RF24_CRC_DISABLED); + + mRadio->setChannel(DEFAULT_RECV_CHANNEL); + mRadio->startListening(); + + RESTORE_IRQ; +} + + +//----------------------------------------------------------------------------- +void app::sendTicker(void) { + uint8_t size = 0; + if((mSendCnt % 6) == 0) + size = mHoymiles->getTimePacket(mSendBuf, mTimestamp); + else if((mSendCnt % 6) == 5) + size = mHoymiles->getCmdPacket(mSendBuf, 0x15, 0x81); + else if((mSendCnt % 6) == 4) + size = mHoymiles->getCmdPacket(mSendBuf, 0x15, 0x80); + else if((mSendCnt % 6) == 3) + size = mHoymiles->getCmdPacket(mSendBuf, 0x15, 0x83); + else if((mSendCnt % 6) == 2) + size = mHoymiles->getCmdPacket(mSendBuf, 0x15, 0x82); + else if((mSendCnt % 6) == 1) + size = mHoymiles->getCmdPacket(mSendBuf, 0x15, 0x84); + + Serial.println("sent packet: #" + String(mSendCnt)); + dumpBuf(mSendBuf, size); + sendPacket(mSendBuf, size); + + mSendCnt++; +} + + +//----------------------------------------------------------------------------- +void app::showIndex(void) { + String html = index_html; + html.replace("{DEVICE}", mDeviceName); + html.replace("{VERSION}", mVersion); + mWeb->send(200, "text/html", html); +} + + +//----------------------------------------------------------------------------- +void app::showSetup(void) { + // overrides same method in main.cpp + + String html = setup_html; + html.replace("{SSID}", mStationSsid); + // PWD will be left at the default value (for protection) + // -> the PWD will only be changed if it does not match the placeholder "{PWD}" + + char addr[20] = {0}; + sprintf(addr, "%02X:%02X:%02X:%02X:%02X:%02X", mHoymiles->mAddrBytes[0], mHoymiles->mAddrBytes[1], mHoymiles->mAddrBytes[2], mHoymiles->mAddrBytes[3], mHoymiles->mAddrBytes[4], mHoymiles->mAddrBytes[5]); + html.replace("{HOY_ADDR}", String(addr)); + + html.replace("{DEVICE}", String(mDeviceName)); + html.replace("{VERSION}", String(mVersion)); + + mWeb->send(200, "text/html", html); +} + + +//----------------------------------------------------------------------------- +void app::showSave(void) { + saveValues(true); +} + + +//----------------------------------------------------------------------------- +void app::saveValues(bool webSend = true) { + Main::saveValues(false); // general configuration + + if(mWeb->args() > 0) { + char *p; + char addr[20] = {0}; + uint8_t i = 0; + + memset(mHoymiles->mAddrBytes, 0, 6); + mWeb->arg("hoy_addr").toCharArray(addr, 20); + + p = strtok(addr, ":"); + while(NULL != p) { + mHoymiles->mAddrBytes[i++] = strtol(p, NULL, 16); + p = strtok(NULL, ":"); + } + + mEep->write(ADDR_HOY_ADDR, mHoymiles->mAddrBytes, HOY_ADDR_LEN); + + if((mWeb->arg("reboot") == "on")) + showReboot(); + else { + mWeb->send(200, "text/html", "Setup saved" + "

saved

"); + } + } + else { + mWeb->send(200, "text/html", "Error" + "

Error while saving

"); + } +} + + +//----------------------------------------------------------------------------- +void app::dumpBuf(uint8_t buf[], uint8_t len) { + for(uint8_t i = 0; i < len; i ++) { + if((i % 8 == 0) && (i != 0)) + Serial.println(); + Serial.print(String(buf[i], HEX) + " "); + } + Serial.println(); +} diff --git a/tools/esp8266/app.h b/tools/esp8266/app.h new file mode 100644 index 000000000..147693b8d --- /dev/null +++ b/tools/esp8266/app.h @@ -0,0 +1,50 @@ +#ifndef __APP_H__ +#define __APP_H__ + +#include +#include + +#include "defines.h" +#include "main.h" + +#include "CircularBuffer.h" +#include "hoymiles.h" + + +class app : public Main { + public: + app(); + ~app(); + + void setup(const char *ssid, const char *pwd, uint32_t timeout); + void loop(void); + void handleIntr(void); + + private: + void initRadio(void); + void sendPacket(uint8_t data[], uint8_t length); + + void sendTicker(void); + + void showIndex(void); + void showSetup(void); + void showSave(void); + + void saveValues(bool webSend); + void dumpBuf(uint8_t buf[], uint8_t len); + + uint8_t mState; + bool mKeyPressed; + + RF24 *mRadio; + hoymiles *mHoymiles; + CircularBuffer *mBufCtrl; + NRF24_packet_t mBuffer[PACKET_BUFFER_SIZE]; + + + Ticker *mSendTicker; + uint32_t mSendCnt; + uint8_t mSendBuf[MAX_RF_PAYLOAD_SIZE]; +}; + +#endif /*__APP_H__*/ diff --git a/tools/esp8266/defines.h b/tools/esp8266/defines.h new file mode 100644 index 000000000..34614e8fb --- /dev/null +++ b/tools/esp8266/defines.h @@ -0,0 +1,37 @@ +#ifndef __DEFINES_H__ +#define __DEFINES_H__ + + +//------------------------------------- +// PINOUT +//------------------------------------- +#define RF24_IRQ_PIN 4 +#define RF24_CE_PIN 5 +#define RF24_CS_PIN 15 + + +//------------------------------------- +// VERSION +//------------------------------------- +#define VERSION_MAJOR 0 +#define VERSION_MINOR 1 +#define VERSION_PATCH 7 + + +//------------------------------------- +// EEPROM +//------------------------------------- +#define SSID_LEN 32 +#define PWD_LEN 64 +#define DEVNAME_LEN 32 + +#define HOY_ADDR_LEN 6 + + +#define ADDR_SSID 0 // start address +#define ADDR_PWD ADDR_SSID + SSID_LEN +#define ADDR_DEVNAME ADDR_PWD + PWD_LEN + +#define ADDR_HOY_ADDR ADDR_DEVNAME + DEVNAME_LEN + +#endif /*__DEFINES_H__*/ diff --git a/tools/esp8266/eep.cpp b/tools/esp8266/eep.cpp new file mode 100644 index 000000000..97a3c600a --- /dev/null +++ b/tools/esp8266/eep.cpp @@ -0,0 +1,130 @@ +#include "eep.h" +#include + + +//----------------------------------------------------------------------------- +eep::eep() { + EEPROM.begin(500); +} + + +//----------------------------------------------------------------------------- +eep::~eep() { + EEPROM.end(); +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, char *str, uint8_t length) { + for(uint8_t i = 0; i < length; i ++) { + *(str++) = (char)EEPROM.read(addr++); + } +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, float *value) { + uint8_t *p = (uint8_t*)value; + for(uint8_t i = 0; i < 4; i ++) { + *(p++) = (uint8_t)EEPROM.read(addr++); + } +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, bool *value) { + uint8_t intVal = 0x00; + intVal = EEPROM.read(addr++); + *value = (intVal == 0x01); +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, uint8_t *value) { + *value = (EEPROM.read(addr++)); +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, uint8_t data[], uint8_t length) { + for(uint8_t i = 0; i < length; i ++) { + *(data++) = EEPROM.read(addr++); + } +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, uint16_t *value) { + *value = (EEPROM.read(addr++) << 8); + *value |= (EEPROM.read(addr++)); +} + + +//----------------------------------------------------------------------------- +void eep::read(uint32_t addr, uint32_t *value) { + *value = (EEPROM.read(addr++) << 24); + *value |= (EEPROM.read(addr++) << 16); + *value |= (EEPROM.read(addr++) << 8); + *value |= (EEPROM.read(addr++)); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, const char *str, uint8_t length) { + for(uint8_t i = 0; i < length; i ++) { + EEPROM.write(addr++, str[i]); + } + EEPROM.commit(); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, uint8_t data[], uint8_t length) { + for(uint8_t i = 0; i < length; i ++) { + EEPROM.write(addr++, data[i]); + } + EEPROM.commit(); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, float value) { + uint8_t *p = (uint8_t*)&value; + for(uint8_t i = 0; i < 4; i ++) { + EEPROM.write(addr++, p[i]); + } + EEPROM.commit(); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, bool value) { + uint8_t intVal = (value) ? 0x01 : 0x00; + EEPROM.write(addr++, intVal); + EEPROM.commit(); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, uint8_t value) { + EEPROM.write(addr++, value); + EEPROM.commit(); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, uint16_t value) { + EEPROM.write(addr++, (value >> 8) & 0xff); + EEPROM.write(addr++, (value ) & 0xff); + EEPROM.commit(); +} + + +//----------------------------------------------------------------------------- +void eep::write(uint32_t addr, uint32_t value) { + EEPROM.write(addr++, (value >> 24) & 0xff); + EEPROM.write(addr++, (value >> 16) & 0xff); + EEPROM.write(addr++, (value >> 8) & 0xff); + EEPROM.write(addr++, (value ) & 0xff); + EEPROM.commit(); +} diff --git a/tools/esp8266/eep.h b/tools/esp8266/eep.h new file mode 100644 index 000000000..36b039144 --- /dev/null +++ b/tools/esp8266/eep.h @@ -0,0 +1,30 @@ +#ifndef __EEP_H__ +#define __EEP_H__ + +#include "Arduino.h" + +class eep { + public: + eep(); + ~eep(); + + void read(uint32_t addr, char *str, uint8_t length); + void read(uint32_t addr, float *value); + void read(uint32_t addr, bool *value); + void read(uint32_t addr, uint8_t *value); + void read(uint32_t addr, uint8_t data[], uint8_t length); + void read(uint32_t addr, uint16_t *value); + void read(uint32_t addr, uint32_t *value); + void write(uint32_t addr, const char *str, uint8_t length); + void write(uint32_t addr, uint8_t data[], uint8_t length); + void write(uint32_t addr, float value); + void write(uint32_t addr, bool value); + void write(uint32_t addr, uint8_t value); + void write(uint32_t addr, uint16_t value); + void write(uint32_t addr, uint32_t value); + + private: + +}; + +#endif /*__EEP_H__*/ diff --git a/tools/esp8266/hoymiles.h b/tools/esp8266/hoymiles.h new file mode 100644 index 000000000..26724ba7e --- /dev/null +++ b/tools/esp8266/hoymiles.h @@ -0,0 +1,228 @@ +#ifndef __HOYMILES_H__ +#define __HOYMILES_H__ + +#include +#include + +#define CHANNEL_HOP // switch between channels or use static channel to send + +#define luint64_t long long unsigned int + +#define DEFAULT_RECV_CHANNEL 3 +#define MAX_RF_PAYLOAD_SIZE 32 +#define DTU_RADIO_ID ((uint64_t)0x1234567801ULL) +#define DUMMY_RADIO_ID ((uint64_t)0xDEADBEEF01ULL) + +#define PACKET_BUFFER_SIZE 30 +#define CRC8_INIT 0x00 +#define CRC8_POLY 0x01 + +#define CRC16_MODBUS_POLYNOM 0xA001 +#define CRC16_NRF24_POLYNOM 0x1021 + + +//----------------------------------------------------------------------------- +// MACROS +#define CP_U32_LittleEndian(buf, v) ({ \ + uint8_t *b = buf; \ + b[0] = ((v >> 24) & 0xff); \ + b[1] = ((v >> 16) & 0xff); \ + b[2] = ((v >> 8) & 0xff); \ + b[3] = ((v ) & 0xff); \ +}) + +#define CP_U32_BigEndian(buf, v) ({ \ + uint8_t *b = buf; \ + b[3] = ((v >> 24) & 0xff); \ + b[2] = ((v >> 16) & 0xff); \ + b[1] = ((v >> 8) & 0xff); \ + b[0] = ((v ) & 0xff); \ +}) + +#define BIT_CNT(x) ((x)<<3) + + +//----------------------------------------------------------------------------- +union uint64Bytes { + uint64_t ull; + uint8_t bytes[8]; +}; + +typedef struct { + uint32_t timestamp; + uint8_t packetsLost; + uint8_t packet[MAX_RF_PAYLOAD_SIZE]; +} NRF24_packet_t; + + +//----------------------------------------------------------------------------- +class hoymiles { + public: + hoymiles() { + serial2RadioId(); + calcDtuIdCrc(); + + mChannels = new uint8_t(4); + mChannels[0] = 23; + mChannels[1] = 40; + mChannels[2] = 61; + mChannels[3] = 75; + mChanIdx = 1; + + mLastCrc = 0x0000; + mRptCnt = 0; + } + + ~hoymiles() {} + + uint8_t getDefaultChannel(void) { + return mChannels[1]; + } + + uint8_t getNxtChannel(void) { + if(++mChanIdx >= 4) + mChanIdx = 0; + return mChannels[mChanIdx]; + } + + void serial2RadioId(void) { + uint64Bytes id; + + id.ull = 0ULL; + id.bytes[4] = mAddrBytes[5]; + id.bytes[3] = mAddrBytes[4]; + id.bytes[2] = mAddrBytes[3]; + id.bytes[1] = mAddrBytes[2]; + id.bytes[0] = 0x01; + + mRadioId = id.ull; + } + + uint8_t getTimePacket(uint8_t buf[], uint32_t ts) { + memset(buf, 0, MAX_RF_PAYLOAD_SIZE); + + buf[0] = 0x15; + CP_U32_BigEndian(&buf[1], (mRadioId >> 8)); + CP_U32_BigEndian(&buf[5], (DTU_RADIO_ID >> 8)); + buf[9] = 0x00; + buf[10] = 0x0b; // cid + buf[11] = 0x00; + CP_U32_LittleEndian(&buf[12], ts); + buf[19] = 0x05; + + uint16_t crc = crc16(&buf[10], 14); + buf[24] = (crc >> 8) & 0xff; + buf[25] = (crc ) & 0xff; + buf[26] = crc8(buf, 26); + + return 27; + } + + uint8_t getCmdPacket(uint8_t buf[], uint8_t mid, uint8_t cmd) { + buf[0] = mid; + CP_U32_BigEndian(&buf[1], (mRadioId >> 8)); + CP_U32_BigEndian(&buf[5], (DTU_RADIO_ID >> 8)); + buf[9] = cmd; + buf[10] = crc8(buf, 10); + + return 11; + } + + bool checkCrc(uint8_t buf[], uint8_t *len, uint8_t *rptCnt) { + *len = (buf[0] >> 2); + for (int16_t i = MAX_RF_PAYLOAD_SIZE - 1; i >= 0; i--) { + buf[i] = ((buf[i] >> 7) | ((i > 0) ? (buf[i-1] << 1) : 0x00)); + } + uint16_t crc = crc16nrf24(buf, BIT_CNT(*len + 2), 7, mDtuIdCrc); + + bool valid = (crc == ((buf[*len+2] << 8) | (buf[*len+3]))); + + if(valid) { + if(mLastCrc == crc) + *rptCnt = (++mRptCnt); + else { + mRptCnt = 0; + *rptCnt = 0; + mLastCrc = crc; + } + } + + return valid; + } + + void dumpBuf(const char *info, uint8_t buf[], uint8_t len) { + Serial.print(String(info)); + for(uint8_t i = 0; i < len; i++) { + Serial.print(buf[i], HEX); + Serial.print(" "); + } + Serial.println(); + } + + uint8_t mAddrBytes[6]; + luint64_t mRadioId; + + private: + void calcDtuIdCrc(void) { + uint64_t addr = DTU_RADIO_ID; + uint8_t dtuAddr[5]; + for(int8_t i = 4; i >= 0; i--) { + dtuAddr[i] = addr; + addr >>= 8; + } + mDtuIdCrc = crc16nrf24(dtuAddr, BIT_CNT(5)); + } + + uint8_t crc8(uint8_t buf[], uint8_t len) { + uint8_t crc = CRC8_INIT; + for(uint8_t i = 0; i < len; i++) { + crc ^= buf[i]; + for(uint8_t b = 0; b < 8; b ++) { + crc = (crc << 1) ^ ((crc & 0x80) ? CRC8_POLY : 0x00); + } + } + return crc; + } + + uint16_t crc16(uint8_t buf[], uint8_t len) { + uint16_t crc = 0xffff; + uint8_t lsb; + + for(uint8_t i = 0; i < len; i++) { + crc = crc ^ buf[i]; + for(int8_t b = 7; b >= 0; b--) { + lsb = (crc & 0x0001); + if(lsb == 0x01) + crc--; + crc = crc >> 1; + if(lsb == 0x01) + crc = crc ^ CRC16_MODBUS_POLYNOM; + } + } + + return crc; + } + + uint16_t crc16nrf24(uint8_t buf[], uint16_t lenBits, uint16_t startBit = 0, uint16_t crcIn = 0xffff) { + uint16_t crc = crcIn; + uint8_t idx, val = buf[(startBit >> 3)]; + + for(uint16_t bit = startBit; bit < lenBits; bit ++) { + idx = bit & 0x07; + if(0 == idx) + val = buf[(bit >> 3)]; + crc ^= 0x8000 & (val << (8 + idx)); + crc = (crc & 0x8000) ? ((crc << 1) ^ CRC16_NRF24_POLYNOM) : (crc << 1); + } + + return crc; + } + + uint8_t *mChannels; + uint8_t mChanIdx; + uint16_t mDtuIdCrc; + uint16_t mLastCrc; + uint8_t mRptCnt; +}; + +#endif /*__HOYMILES_H__*/ diff --git a/tools/esp8266/html/conv.bat b/tools/esp8266/html/conv.bat new file mode 100644 index 000000000..46587400b --- /dev/null +++ b/tools/esp8266/html/conv.bat @@ -0,0 +1,4 @@ +..\tools\fileConv.exe index.html h\index_html.h index_html +..\tools\fileConv.exe setup.html h\setup_html.h setup_html +..\tools\fileConv.exe style.css h\style_css.h style_css +pause diff --git a/tools/esp8266/html/h/index_html.h b/tools/esp8266/html/h/index_html.h new file mode 100644 index 000000000..81ea07756 --- /dev/null +++ b/tools/esp8266/html/h/index_html.h @@ -0,0 +1 @@ +String index_html = "Index - {DEVICE}

AHOY - {DEVICE}

Update

Setup
Reboot

Uptime:

Time:

© 2022

AHOY :: {VERSION}

"; diff --git a/tools/esp8266/html/h/setup_html.h b/tools/esp8266/html/h/setup_html.h new file mode 100644 index 000000000..24440e88a --- /dev/null +++ b/tools/esp8266/html/h/setup_html.h @@ -0,0 +1 @@ +String setup_html = "Setup - {DEVICE}

Setup

Enter the credentials to your prefered WiFi station. After rebooting the device tries to connect with this information.

WiFi

SSID
PASSWORD

Device Host Name

DEVICE NAME

General

HOYMILES ADDRESS (eg. 11:22:33:44:55:66)

Home

Update Firmware

AHOY - {VERSION}

"; diff --git a/tools/esp8266/html/h/style_css.h b/tools/esp8266/html/h/style_css.h new file mode 100644 index 000000000..be1e07616 --- /dev/null +++ b/tools/esp8266/html/h/style_css.h @@ -0,0 +1 @@ +String style_css = "h1 { margin: 0; padding: 20pt; font-size: 22pt; color: #fff; background-color: #006ec0; display: block; text-transform: uppercase; } html, body { font-family: Arial; margin: 0; padding: 0; } p { text-align: justify; font-size: 13pt; } .des { font-size: 14pt; color: #006ec0; padding-bottom: 0px !important; } .fw { width: 60px; display: block; float: left; } .color { width: 50px; height: 50px; border: 1px solid #ccc; } .range { width: 300px; } a:link, a:visited { text-decoration: none; font-size: 13pt; color: #006ec0; } a:hover, a:focus { color: #f00; } #content { padding: 15px 15px 60px 15px; } #footer { position: fixed; bottom: 0px; height: 45px; background-color: #006ec0; width: 100%; } #footer p { color: #fff; padding-left: 20px; padding-right: 20px; font-size: 10pt !important; } #footer a { color: #fff; } #footer a:hover { color: #f00; } div.content { background-color: #fff; padding-bottom: 65px; overflow: hidden; } span.warn { display: inline-block; padding-left: 20px; color: #ff9900; font-style: italic; } input { padding: 10px; font-size: 13pt; } input.button { background-color: #006ec0; color: #fff; border: 0px; float: right; text-transform: uppercase; } input.cb { margin-bottom: 20px; } label { font-size: 14pt; } .left { float: left; } .right { float: right; } .inputWrp { position: relative; } .inputWrp .inputText { height: 35px; width: 90%; margin-bottom: 20px; border: 1px solid #ccc; border-top: none; border-right: none; } .inputWrp .floating_label { position: absolute; pointer-events: none; top: 20px; left: 10px; transition: 0.2s ease all; } .inputWrp input:focus ~ .floating_label, .inputWrp input:not(:focus):valid ~ .floating_label { top: 0px; left: 20px; font-size: 10px; color: blue; opacity: 1; } "; diff --git a/tools/esp8266/html/index.html b/tools/esp8266/html/index.html new file mode 100644 index 000000000..479cc4837 --- /dev/null +++ b/tools/esp8266/html/index.html @@ -0,0 +1,45 @@ + + + + Index - {DEVICE} + + + + + +

AHOY - {DEVICE}

+
+

+ Update
+
+ Setup
+ Reboot +

+

Uptime:

+

Time:

+
+ + + diff --git a/tools/esp8266/html/setup.html b/tools/esp8266/html/setup.html new file mode 100644 index 000000000..393fce78e --- /dev/null +++ b/tools/esp8266/html/setup.html @@ -0,0 +1,51 @@ + + + + Setup - {DEVICE} + + + + +

Setup

+
+
+

+ Enter the credentials to your prefered WiFi station. After rebooting the device tries to connect with this information. +

+
+

WiFi

+
+ + SSID +
+
+ + PASSWORD +
+

Device Host Name

+
+ + DEVICE NAME +
+ +

General

+ +
+ + HOYMILES ADDRESS (eg. 11:22:33:44:55:66) +
+ + + + +
+
+
+ + + + diff --git a/tools/esp8266/html/style.css b/tools/esp8266/html/style.css new file mode 100644 index 000000000..7d6daaa74 --- /dev/null +++ b/tools/esp8266/html/style.css @@ -0,0 +1,151 @@ +h1 { + margin: 0; + padding: 20pt; + font-size: 22pt; + color: #fff; + background-color: #006ec0; + display: block; + text-transform: uppercase; +} + +html, body { + font-family: Arial; + margin: 0; + padding: 0; +} + +p { + text-align: justify; + font-size: 13pt; +} + +.des { + font-size: 14pt; + color: #006ec0; + padding-bottom: 0px !important; +} + +.fw { + width: 60px; + display: block; + float: left; +} + +.color { + width: 50px; + height: 50px; + border: 1px solid #ccc; +} + +.range { + width: 300px; +} + +a:link, a:visited { + text-decoration: none; + font-size: 13pt; + color: #006ec0; +} + +a:hover, a:focus { + color: #f00; +} + +#content { + padding: 15px 15px 60px 15px; +} + +#footer { + position: fixed; + bottom: 0px; + height: 45px; + background-color: #006ec0; + width: 100%; +} + +#footer p { + color: #fff; + padding-left: 20px; + padding-right: 20px; + font-size: 10pt !important; +} + +#footer a { + color: #fff; +} + +#footer a:hover { + color: #f00; +} + +div.content { + background-color: #fff; + padding-bottom: 65px; + overflow: hidden; +} + +span.warn { + display: inline-block; + padding-left: 20px; + color: #ff9900; + font-style: italic; +} + +input { + padding: 10px; + font-size: 13pt; +} + +input.button { + background-color: #006ec0; + color: #fff; + border: 0px; + float: right; + text-transform: uppercase; +} + +input.cb { + margin-bottom: 20px; +} + +label { + font-size: 14pt; +} + +.left { + float: left; +} + +.right { + float: right; +} + +.inputWrp { + position: relative; +} + +.inputWrp .inputText { + height: 35px; + width: 90%; + margin-bottom: 20px; + border: 1px solid #ccc; + border-top: none; + border-right: none; +} + +.inputWrp .floating_label { + position: absolute; + pointer-events: none; + top: 20px; + left: 10px; + transition: 0.2s ease all; +} + +.inputWrp input:focus ~ .floating_label, +.inputWrp input:not(:focus):valid ~ .floating_label { + top: 0px; + left: 20px; + font-size: 10px; + color: blue; + opacity: 1; +} diff --git a/tools/esp8266/main.cpp b/tools/esp8266/main.cpp new file mode 100644 index 000000000..5ecaaacaa --- /dev/null +++ b/tools/esp8266/main.cpp @@ -0,0 +1,340 @@ +#include "main.h" +#include "version.h" + +#include "html/h/style_css.h" +#include "html/h/setup_html.h" + +Main::Main(void) { + mDns = new DNSServer(); + mWeb = new ESP8266WebServer(80); + mUpdater = new ESP8266HTTPUpdateServer(); + mUdp = new WiFiUDP(); + + mApActive = true; + + snprintf(mVersion, 12, "%d.%d.%d", VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH); + + memset(&mDeviceName, 0, DEVNAME_LEN); + + mEep = new eep(); + Serial.begin(115200); + + mUptimeSecs = 0; + mUptimeTicker = new Ticker(); + mUptimeTicker->attach(1, std::bind(&Main::uptimeTicker, this)); +} + + +//----------------------------------------------------------------------------- +void Main::setup(const char *ssid, const char *pwd, uint32_t timeout) { + bool startAp = mApActive; + + mWeb->on("/setup", std::bind(&Main::showSetup, this)); + mWeb->on("/save", std::bind(&Main::showSave, this)); + mWeb->on("/uptime", std::bind(&Main::showUptime, this)); + mWeb->on("/time", std::bind(&Main::showTime, this)); + mWeb->on("/style.css", std::bind(&Main::showCss, this)); + mWeb->on("/reboot", std::bind(&Main::showReboot, this)); + mWeb->onNotFound (std::bind(&Main::showNotFound, this)); + + startAp = getConfig(); + if(String(mDeviceName) != "") + WiFi.hostname(mDeviceName); + + if(false == startAp) + startAp = setupStation(timeout); + + if(true == startAp) { + if(strlen(pwd) < 8) + Serial.println("password must be at least 8 characters long"); + setupAp(ssid, pwd); + } + + mUpdater->setup(mWeb); + + mApActive = startAp; + + mTimestamp = getNtpTime(); + //Serial.println("[NTP]: " + getDateTimeStr(getNtpTime())); +} + + +//----------------------------------------------------------------------------- +void Main::loop(void) { + if(mApActive) + mDns->processNextRequest(); + mWeb->handleClient(); +} + + +//----------------------------------------------------------------------------- +bool Main::getConfig(void) { + bool mApActive = false; + + mEep->read(ADDR_SSID, mStationSsid, SSID_LEN); + mEep->read(ADDR_PWD, mStationPwd, PWD_LEN); + mEep->read(ADDR_DEVNAME, mDeviceName, DEVNAME_LEN); + + if(mStationSsid[0] == 0xff) { // empty memory + mApActive = true; + memset(mStationSsid, 0, SSID_LEN); + } + if(mStationPwd[0] == 0xff) + memset(mStationPwd, 0, PWD_LEN); + if(mDeviceName[0] == 0xff) + memset(mDeviceName, 0, DEVNAME_LEN); + + return mApActive; +} + + +//----------------------------------------------------------------------------- +void Main::setupAp(const char *ssid, const char *pwd) { + IPAddress apIp(192, 168, 1, 1); + + WiFi.mode(WIFI_AP); + WiFi.softAPConfig(apIp, apIp, IPAddress(255, 255, 255, 0)); + WiFi.softAP(ssid, pwd); + + mDns->start(mDnsPort, "*", apIp); + + mWeb->onNotFound([&]() { + showSetup(); + }); + mWeb->on("/", std::bind(&Main::showSetup, this)); + + mWeb->begin(); +} + + +//----------------------------------------------------------------------------- +bool Main::setupStation(uint32_t timeout) { + int32_t cnt = timeout * 10; + bool startAp = false; + + WiFi.mode(WIFI_STA); + WiFi.begin(mStationSsid, mStationPwd); + + delay(5000); + Serial.println("wait for network"); + while (WiFi.status() != WL_CONNECTED) { + delay(100); + if(cnt % 100 == 0) + Serial.println("."); + else + Serial.print("."); + + if(timeout > 0) { // limit == 0 -> no limit + if(--cnt <= 0) { + startAp = true; + WiFi.disconnect(); + delay(100); + break; + } + } + } + Serial.println("."); + + if(false == startAp) { + mWeb->begin(); + } + + delay(1000); + + return startAp; +} + + +//----------------------------------------------------------------------------- +void Main::showSetup(void) { + String html = setup_html; + html.replace("{SSID}", mStationSsid); + // PWD will be left at the default value (for protection) + // -> the PWD will only be changed if it does not match the default "{PWD}" + html.replace("{DEVICE}", String(mDeviceName)); + html.replace("{VERSION}", String(mVersion)); + + mWeb->send(200, "text/html", html); +} + + +//----------------------------------------------------------------------------- +void Main::showCss(void) { + mWeb->send(200, "text/css", style_css); +} + + +//----------------------------------------------------------------------------- +void Main::showSave(void) { + saveValues(true); +} + + +//----------------------------------------------------------------------------- +void Main::saveValues(bool webSend = true) { + if(mWeb->args() > 0) { + if(mWeb->arg("ssid") != "") { + memset(mStationSsid, 0, SSID_LEN); + mWeb->arg("ssid").toCharArray(mStationSsid, SSID_LEN); + mEep->write(ADDR_SSID, mStationSsid, SSID_LEN); + + if(mWeb->arg("pwd") != "{PWD}") { + memset(mStationPwd, 0, PWD_LEN); + mWeb->arg("pwd").toCharArray(mStationPwd, PWD_LEN); + mEep->write(ADDR_PWD, mStationPwd, PWD_LEN); + } + } + + memset(mDeviceName, 0, DEVNAME_LEN); + mWeb->arg("device").toCharArray(mDeviceName, DEVNAME_LEN); + mEep->write(ADDR_DEVNAME, mDeviceName, DEVNAME_LEN); + + + if(webSend) { + if(mWeb->arg("reboot") == "on") + showReboot(); + else + mWeb->send(200, "text/html", "Setup saved" + "

saved

"); + } + } +} + + +//----------------------------------------------------------------------------- +void Main::showUptime(void) { + char time[20] = {0}; + + int upTimeSc = uint32_t((mUptimeSecs) % 60); + int upTimeMn = uint32_t((mUptimeSecs / (60)) % 60); + int upTimeHr = uint32_t((mUptimeSecs / (60 * 60)) % 24); + int upTimeDy = uint32_t((mUptimeSecs / (60 * 60 * 24)) % 365); + + snprintf(time, 20, "%d Tage, %02d:%02d:%02d", upTimeDy, upTimeHr, upTimeMn, upTimeSc); + + mWeb->send(200, "text/plain", String(time)); +} + + +//----------------------------------------------------------------------------- +void Main::showTime(void) { + mWeb->send(200, "text/plain", getDateTimeStr(mTimestamp)); +} + + +//----------------------------------------------------------------------------- +void Main::showNotFound(void) { + String msg = "File Not Found\n\n"; + msg += "URI: "; + msg += mWeb->uri(); + msg += "\nMethod: "; + msg += ( mWeb->method() == HTTP_GET ) ? "GET" : "POST"; + msg += "\nArguments: "; + msg += mWeb->args(); + msg += "\n"; + + for(uint8_t i = 0; i < mWeb->args(); i++ ) { + msg += " " + mWeb->argName(i) + ": " + mWeb->arg(i) + "\n"; + } + + mWeb->send(404, "text/plain", msg); +} + + +//----------------------------------------------------------------------------- +void Main::showReboot(void) { + mWeb->send(200, "text/html", "Rebooting ...rebooting ... auto reload after 10s"); + delay(1000); + ESP.restart(); +} + + + +//----------------------------------------------------------------------------- +void Main::uptimeTicker(void) { + mUptimeSecs++; + mTimestamp++; +} + + +//----------------------------------------------------------------------------- +time_t Main::getNtpTime(void) { + time_t date = 0; + IPAddress timeServer; + uint8_t buf[NTP_PACKET_SIZE]; + uint8_t retry = 0; + + WiFi.hostByName (TIMESERVER_NAME, timeServer); + mUdp->begin(TIME_LOCAL_PORT); + + + sendNTPpacket(timeServer); + + while(retry++ < 5) { + int wait = 150; + while(--wait) { + if(NTP_PACKET_SIZE <= mUdp->parsePacket()) { + uint64_t secsSince1900; + mUdp->read(buf, NTP_PACKET_SIZE); + secsSince1900 = (buf[40] << 24); + secsSince1900 |= (buf[41] << 16); + secsSince1900 |= (buf[42] << 8); + secsSince1900 |= (buf[43] ); + + date = secsSince1900 - 2208988800UL; // UTC time + date += (TIMEZONE + offsetDayLightSaving(date)) * 3600; + break; + } + else + delay(10); + } + } + + return date; +} + + +//----------------------------------------------------------------------------- +void Main::sendNTPpacket(IPAddress& address) { + uint8_t buf[NTP_PACKET_SIZE] = {0}; + + buf[0] = B11100011; // LI, Version, Mode + buf[1] = 0; // Stratum + buf[2] = 6; // Max Interval between messages in seconds + buf[3] = 0xEC; // Clock Precision + // bytes 4 - 11 are for Root Delay and Dispersion and were set to 0 by memset + buf[12] = 49; // four-byte reference ID identifying + buf[13] = 0x4E; + buf[14] = 49; + buf[15] = 52; + + mUdp->beginPacket(address, 123); // NTP request, port 123 + mUdp->write(buf, NTP_PACKET_SIZE); + mUdp->endPacket(); +} + + +//----------------------------------------------------------------------------- +String Main::getDateTimeStr(time_t t) { + char str[20] = {0}; + sprintf(str, "%04d-%02d-%02d+%02d:%02d:%02d", year(t), month(t), day(t), hour(t), minute(t), second(t)); + return String(str); +} + + +//----------------------------------------------------------------------------- +// calculates the daylight saving time for middle Europe. Input: Unixtime in UTC +// from: https://forum.arduino.cc/index.php?topic=172044.msg1278536#msg1278536 +time_t Main::offsetDayLightSaving (uint32_t local_t) { + int m = month (local_t); + if(m < 3 || m > 10) return 0; // no DSL in Jan, Feb, Nov, Dez + if(m > 3 && m < 10) return 1; // DSL in Apr, May, Jun, Jul, Aug, Sep + int y = year (local_t); + int h = hour (local_t); + int hToday = (h + 24 * day(local_t)); + if((m == 3 && hToday >= (1 + TIMEZONE + 24 * (31 - (5 * y /4 + 4) % 7))) + || (m == 10 && hToday < (1 + TIMEZONE + 24 * (31 - (5 * y /4 + 1) % 7))) ) + return 1; + else + return 0; +} diff --git a/tools/esp8266/main.h b/tools/esp8266/main.h new file mode 100644 index 000000000..1f1a91af3 --- /dev/null +++ b/tools/esp8266/main.h @@ -0,0 +1,79 @@ +#ifndef __MAIN_H__ +#define __MAIN_H__ + +#include "Arduino.h" + +#include +#include +#include +#include + +#include + +// NTP +#include +#include + +#include "eep.h" +#include "defines.h" + + +const byte mDnsPort = 53; + +/* TIMESERVER CONFIG */ +#define TIMESERVER_NAME "pool.ntp.org" +#define TIME_LOCAL_PORT 8888 +#define NTP_PACKET_SIZE 48 +#define TIMEZONE 1 // Central European time +1 + +class Main { + public: + Main(void); + virtual void setup(const char *ssid, const char *pwd, uint32_t timeout); + virtual void loop(); + String getDateTimeStr (time_t t); + + + protected: + void showReboot(void); + virtual void saveValues(bool webSend); + + char mStationSsid[SSID_LEN]; + char mStationPwd[PWD_LEN]; + bool mLinkLedActive; + bool mApActive; + ESP8266WebServer *mWeb; + char mVersion[9]; + char mDeviceName[DEVNAME_LEN]; + eep *mEep; + uint32_t mTimestamp; + + + private: + bool getConfig(void); + void setupAp(const char *ssid, const char *pwd); + bool setupStation(uint32_t timeout); + + void showNotFound(void); + virtual void showSetup(void); + virtual void showSave(void); + void showUptime(void); + void showTime(void); + void showCss(void); + void uptimeTicker(void); + + + time_t getNtpTime(void); + void sendNTPpacket(IPAddress& address); + time_t offsetDayLightSaving (uint32_t local_t); + + Ticker *mUptimeTicker; + uint32_t mUptimeSecs; + + DNSServer *mDns; + ESP8266HTTPUpdateServer *mUpdater; + + WiFiUDP *mUdp; // for time server +}; + +#endif /*__MAIN_H__*/ diff --git a/tools/esp8266/tools/fileConv.exe b/tools/esp8266/tools/fileConv.exe new file mode 100644 index 0000000000000000000000000000000000000000..6cd3e212ea1e5d0ccd0a9455a36b66d4d0fbab7d GIT binary patch literal 67584 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 frV+JK