main
Konstantin Láska 2026-06-26 09:27:26 +02:00
parent e9077a979a
commit 0763f5b446
36 changed files with 4584 additions and 3004 deletions

View File

@ -0,0 +1,141 @@
/*
* ESPlan_RS485_Relay.ino
* 8 / 16-kanalovy RS485 rele modul (Modbus-RTU)
*
* Hardware : LaskaKit ESPlan (ESP32 + LAN8720)
* Modul : 8-ch / 16-ch 12V rele 250VAC/10A RS485 DIN
* https://www.laskakit.cz/8-kanalu-12v-rele-modul-250vac-10a--rs485--din/
*
* Soubory projektu:
* cfg.h - HW konstanty, registry, datove struktury
* mb_client.h/cpp - Modbus RTU klient (FC03, FC06)
* relay.h/cpp - Ovladani rele (set/toggle/latch/momentary/delay/all/read)
* i18n_str.h - Preklady CZ / EN
* ws_server.h/cpp - HTTP webserver
*
* Modbus protokol (datasheet 8ch_rele_rs485_protocols.pdf):
* FC 0x06 (Write Single Register), 8N1
* Adresa modulu se nastavuje DIP prepinaci A0-A5 (rozsah 0-47)
* Baudrate (HW pady M1/M2): 9600 (default) / 2400 / 4800 / 19200
*
* reg 0x0001..0x0010 - rele 1..16 (jednotlive kanaly)
* val 0x0100 = Open (sepne)
* val 0x0200 = Close (rozepne)
* val 0x0300 = Toggle (self-locking)
* val 0x0400 = Latch (inter-locking)
* val 0x0500 = Momentary (1s puls)
* val 0x06xx = Delay xx s (0-255)
* reg 0x0000 - vsechny kanaly
* val 0x0700 = Vsechny ON
* val 0x0800 = Vsechny OFF
*
* FC 0x03 cteni stavu: reg 0x0001..0x0010, vrati 0x0001=ON / 0x0000=OFF
*/
#include <Arduino.h>
#include <WiFi.h>
#include <ETH.h>
#include <Preferences.h>
#include "cfg.h"
#include "mb_client.h"
#include "relay.h"
#include "ws_server.h"
// ─── Globalni objekty ─────────────────────────────────────────────────────────
Preferences prefs;
RelayState relayState;
RelayConfig relayCfg;
ModbusClient modbusClient;
static bool ethGotIP = false;
static uint32_t lastReadMs = 0;
static const uint32_t READ_INTERVAL_MS = 5000; // 5s pravidelna sync z modulu
// ─── ETH udalosti ─────────────────────────────────────────────────────────────
void onEthEvent(WiFiEvent_t event) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
Serial.println(F("[ETH] Inicializace..."));
ETH.setHostname("espplan-relay");
break;
case ARDUINO_EVENT_ETH_CONNECTED:
Serial.println(F("[ETH] Pripojen (fyzicka vrstva)"));
break;
case ARDUINO_EVENT_ETH_GOT_IP:
Serial.print(F("[ETH] IP: "));
Serial.println(ETH.localIP());
ethGotIP = true;
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
Serial.println(F("[ETH] Odpojeno"));
ethGotIP = false;
break;
case ARDUINO_EVENT_ETH_STOP:
Serial.println(F("[ETH] Zastaveno"));
break;
default:
break;
}
}
// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println(F("\n=== ESPlan RS485 Relay 8/16ch ==="));
Serial.println(F(" LaskaKit 8/16-ch Modbus-RTU "));
Serial.println(F("============================="));
// Nacteni konfigurace z NVS
prefs.begin("rs485rel", true);
loadConfig(prefs, relayCfg);
prefs.end();
Serial.printf("[CFG] Modbus addr=%d baud=%d\n",
relayCfg.modbusAddr,
relayCfg.baudRate);
// RS485 / Modbus
modbusClient.begin(relayCfg.baudRate, relayCfg.modbusAddr);
// ETH
WiFi.onEvent(onEthEvent);
ETH.begin(ETH_PHY_TYPE,
ETH_PHY_ADDR,
ETH_PHY_MDC,
ETH_PHY_MDIO,
ETH_PHY_POWER,
ETH_CLK_MODE);
uint32_t t0 = millis();
while (!ethGotIP && (millis() - t0 < 10000)) {
delay(100);
}
if (!ethGotIP) {
Serial.println(F("[ETH] VAROVANI: IP neziskano - web nebude dostupny!"));
}
// Webserver
webServerBegin(relayState, relayCfg, prefs, modbusClient);
// Pokus o nacteni stavu (pokud modul podporuje FC03)
Serial.println(F("[REL] Pokus o sync stavu z modulu..."));
relayReadAll(modbusClient, relayState, relayCfg);
Serial.println(F("[SYS] Inicializace dokoncena."));
}
// ─── Loop ─────────────────────────────────────────────────────────────────────
void loop() {
webServerHandle();
// Periodicky pokus o aktualizaci stavu (volitelne)
uint32_t now = millis();
if (now - lastReadMs >= READ_INTERVAL_MS) {
lastReadMs = now;
relayReadAll(modbusClient, relayState, relayCfg);
}
delay(5);
}

132
SW/ESPlan_RS485_Relay/cfg.h Executable file
View File

@ -0,0 +1,132 @@
#ifndef RL_CFG_H
#define RL_CFG_H
/*
* cfg.h Konfigurace HW a datove struktury
*
* Zarizeni: 8 / 16-kanalovy RS485 rele modul (Modbus-RTU)
* https://www.laskakit.cz/8-kanalu-12v-rele-modul-250vac-10a--rs485--din/
*
* Protokol (datasheet 8ch_rele_rs485_protocols.pdf):
* FC 0x06 (Write Single Register), 8N1
* Adresa: 0x00-0x2F (0-47) nastavena DIP prepinaci A0-A5 (HW)
* Baudrate: 9600 / 2400 / 4800 / 19200 (vychozi 9600, nastavi se M1/M2 pady)
*
* Jednotlivy kanal (reg 0x0001 .. 0x000N):
* val_hi = funkce, val_lo = parametr/delay
*
* 0x0100 Open (sepne)
* 0x0200 Close (rozepne)
* 0x0300 Toggle (self-locking)
* 0x0400 Latch (inter-locking; jen tento kanal ON, ostatni OFF)
* 0x0500 Momentary (non-locking, 1s puls)
* 0x06xx Delay XX s (xx = delka v sekundach 0x00..0xFF)
*
* Vsechny kanaly (reg 0x0000):
* 0x0700 All ON
* 0x0800 All OFF
*
* Cteni stavu (FC 0x03):
* reg 0x0001 .. 0x000N, vrati 0x0001 (ON) / 0x0000 (OFF)
*
* Adresa zarizeni se NEMENI Modbusem nastavi DIP prepinacem!
*/
#include <Arduino.h>
#include <Preferences.h>
// ─── ETH (LAN8720 na ESPlan) ──────────────────────────────────────────────────
#define ETH_PHY_TYPE ETH_PHY_LAN8720
#define ETH_PHY_ADDR 0
#define ETH_PHY_MDC 23
#define ETH_PHY_MDIO 18
#define ETH_PHY_POWER -1
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
// ─── RS485 / UART ─────────────────────────────────────────────────────────────
#define PIN_RS485_RX 36
#define PIN_RS485_TX 4
// ─── Modbus vychozi hodnoty ───────────────────────────────────────────────────
#define DEFAULT_MODBUS_ADDR 1
#define DEFAULT_BAUD_RATE 9600
#define DEFAULT_CHANNEL_COUNT 8 // 8 nebo 16
#define MAX_CHANNEL_COUNT 16
#define MODBUS_ADDR_MIN 0
#define MODBUS_ADDR_MAX 47 // 0x2F dle datasheetu (DIP A0-A5)
// ─── Registry rele modulu ────────────────────────────────────────────────────
#define REG_ALL_CHANNELS 0x0000
#define REG_CHANNEL_BASE 0x0001 // reg 0x0001 = ch1, az 0x0010 = ch16
// ─── Funkcni kody (val_hi) ───────────────────────────────────────────────────
#define FN_OPEN 0x01
#define FN_CLOSE 0x02
#define FN_TOGGLE 0x03
#define FN_LATCH 0x04
#define FN_MOMENTARY 0x05
#define FN_DELAY 0x06 // val_lo = delka v s (0-255)
#define FN_ALL_ON 0x07 // jen s reg 0x0000
#define FN_ALL_OFF 0x08 // jen s reg 0x0000
// helper na slozeni val pole pro Modbus zapis
inline uint16_t makeVal(uint8_t fn, uint8_t param = 0) {
return ((uint16_t)fn << 8) | param;
}
// ─── Stav rele modulu ────────────────────────────────────────────────────────
struct RelayState {
bool channels[MAX_CHANNEL_COUNT] = { false }; // SW shadow (true = ON)
uint32_t lastUpdateMs = 0;
uint32_t lastCmdMs = 0;
bool commError = false;
String errorMsg = "";
String lastAction = ""; // pro UI
};
// ─── Konfigurace (uklada se do NVS) ──────────────────────────────────────────
struct RelayConfig {
uint8_t modbusAddr = DEFAULT_MODBUS_ADDR;
uint32_t baudRate = DEFAULT_BAUD_RATE;
uint8_t channelCount = DEFAULT_CHANNEL_COUNT; // 8 nebo 16
uint8_t defaultDelay = 10; // pro tlacitko Delay (sekundy)
// Nazvy kanalu (max 15 znaku + \0)
char names[MAX_CHANNEL_COUNT][16] = {
"Rele 1", "Rele 2", "Rele 3", "Rele 4",
"Rele 5", "Rele 6", "Rele 7", "Rele 8",
"Rele 9", "Rele 10", "Rele 11", "Rele 12",
"Rele 13", "Rele 14", "Rele 15", "Rele 16"
};
};
// ─── NVS load / save ─────────────────────────────────────────────────────────
inline void loadConfig(Preferences &p, RelayConfig &c) {
c.modbusAddr = p.getUChar("mbAddr", DEFAULT_MODBUS_ADDR);
c.baudRate = p.getUInt ("baud", DEFAULT_BAUD_RATE);
c.channelCount = p.getUChar("chCount", DEFAULT_CHANNEL_COUNT);
if (c.channelCount != 8 && c.channelCount != 16) c.channelCount = 8;
c.defaultDelay = p.getUChar("defDelay", 10);
for (uint8_t i = 0; i < MAX_CHANNEL_COUNT; i++) {
char key[8];
snprintf(key, sizeof(key), "n%u", i);
String def = String("Rele ") + (i + 1);
String v = p.getString(key, def);
strncpy(c.names[i], v.c_str(), 15);
c.names[i][15] = 0;
}
}
inline void saveConfig(Preferences &p, const RelayConfig &c) {
p.putUChar("mbAddr", c.modbusAddr);
p.putUInt ("baud", c.baudRate);
p.putUChar("chCount", c.channelCount);
p.putUChar("defDelay", c.defaultDelay);
for (uint8_t i = 0; i < MAX_CHANNEL_COUNT; i++) {
char key[8];
snprintf(key, sizeof(key), "n%u", i);
p.putString(key, c.names[i]);
}
}
#endif // RL_CFG_H

187
SW/ESPlan_RS485_Relay/i18n_str.h Executable file
View File

@ -0,0 +1,187 @@
#ifndef RL_I18N_STR_H
#define RL_I18N_STR_H
/*
* i18n_str.h Preklady webove rozhrani (CZ / EN)
* pro 8/16-kanalovy RS485 rele modul
*/
#include <Arduino.h>
struct I18nStrings {
// Navigace
const char* title;
const char* navStatus;
const char* navConfig;
const char* langSwitch;
// Stavova stranka
const char* pageStatusTitle;
const char* sectionRelays;
const char* sectionActions;
const char* sectionAdvanced;
const char* colChannel;
const char* colName;
const char* colState;
const char* colCtrl;
const char* stateOn;
const char* stateOff;
const char* btnOn;
const char* btnOff;
const char* btnToggle;
const char* btnLatch;
const char* btnMomentary;
const char* btnDelay;
const char* btnAllOn;
const char* btnAllOff;
const char* commError;
const char* lastAction;
const char* labelDelay;
const char* labelDelaySec;
const char* labelLatchHelp;
const char* labelMomentaryHelp;
const char* labelDelayHelp;
// Konfigurace
const char* pageConfigTitle;
const char* sectionComm;
const char* cfgAddr;
const char* cfgAddrHelp;
const char* cfgBaud;
const char* cfgBaudHelp;
const char* cfgChannels;
const char* cfgChannelsHelp;
const char* cfgDefDelay;
const char* cfgDefDelayHelp;
const char* sectionNames;
const char* cfgNameHelp;
const char* sectionInfo;
const char* infoDip;
const char* btnSave;
const char* sectionRaw;
};
// ─── Cestina ──────────────────────────────────────────────────────────────────
static const I18nStrings STR_CZ = {
"ESPlan RS485 rele",
"Ovladani",
"Konfigurace",
"English",
"Ovladani rele",
"Stav kanalu",
"Hromadne ovladani",
"Specialni rezimy",
"#",
"Nazev",
"Stav",
"Akce",
"ON",
"OFF",
"ON",
"OFF",
"Toggle",
"Latch",
"Pulse",
"Delay",
"Vsechny ON",
"Vsechny OFF",
"&#9888; Chyba komunikace",
"Posledni akce",
"Delay (sekundy)",
"s",
"Latch (inter-locking) sepne jen vybrany kanal a ostatni rozepne.",
"Pulse (momentary) sepne kanal na 1 sekundu, pak automaticky rozepne.",
"Delay sepne kanal, po zadanem case ho automaticky rozepne (0-255 s).",
"Konfigurace",
"Komunikacni parametry",
"Modbus adresa",
"Adresa modulu nastavena DIP prepinaci A0-A5 na desce (0-47).",
"Prenosova rychlost",
"Baudrate (nastavi se HW pady M1/M2): 9600 (default), 2400, 4800, 19200.",
"Pocet kanalu",
"8-kanalova nebo 16-kanalova varianta modulu.",
"Vychozi Delay",
"Pocatecni hodnota Delay tlacitka (0-255 s).",
"Nazvy kanalu",
"Pojmenovani jednotlivych kanalu (max 15 znaku)",
"Informace o zarizeni",
"Adresa Modbus modulu se nastavuje DIP prepinaci A0-A5 (binarne, 6 bitu). "
"Baudrate se nastavi propojkami M1/M2: 9600 / 2400 / 4800 / 19200. "
"Modul take podporuje AT prikazy pres UART, pokud zkratite pady M0 - v tom rezimu "
"Slave ID nepouziva, a tento ESP32 program ho neumi (pouziva pouze Modbus-RTU).",
"Ulozit konfiguraci",
"RS485 komunikace (raw)"
};
// ─── Anglictina ───────────────────────────────────────────────────────────────
static const I18nStrings STR_EN = {
"ESPlan RS485 relay",
"Control",
"Configuration",
"Cesky",
"Relay control",
"Channel state",
"Bulk control",
"Advanced modes",
"#",
"Name",
"State",
"Action",
"ON",
"OFF",
"ON",
"OFF",
"Toggle",
"Latch",
"Pulse",
"Delay",
"All ON",
"All OFF",
"&#9888; Communication error",
"Last action",
"Delay (seconds)",
"s",
"Latch (inter-locking) turns ON only the selected channel and turns OFF all others.",
"Pulse (momentary) closes the channel for 1 second, then opens automatically.",
"Delay closes the channel and reopens it after the given time (0-255 s).",
"Configuration",
"Communication Settings",
"Modbus address",
"Module address is set by DIP switches A0-A5 on the board (0-47).",
"Baud rate",
"Baud rate (set by HW pads M1/M2): 9600 (default), 2400, 4800, 19200.",
"Channel count",
"8-channel or 16-channel variant.",
"Default Delay",
"Initial value for the Delay button (0-255 s).",
"Channel names",
"Name each channel (max 15 chars)",
"Device info",
"Module Modbus address is set by DIP switches A0-A5 (6-bit binary). "
"Baud rate is selected by jumper pads M1/M2: 9600 / 2400 / 4800 / 19200. "
"The module also supports AT commands over UART when M0 pads are shorted - "
"Slave ID is then unused, and this firmware does not use that mode (Modbus-RTU only).",
"Save configuration",
"RS485 Communication (raw)"
};
inline const I18nStrings& getStrings(bool isCz) {
return isCz ? STR_CZ : STR_EN;
}
#endif // RL_I18N_STR_H

View File

@ -0,0 +1,158 @@
/*
* mb_client.cpp Implementace Modbus RTU klienta
*/
#include "mb_client.h"
uint16_t ModbusClient::crc16(const uint8_t* data, uint8_t len) {
uint16_t crc = 0xFFFF;
for (uint8_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i];
for (uint8_t b = 0; b < 8; b++) {
if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001;
else crc >>= 1;
}
}
return crc;
}
void ModbusClient::flushRx() {
while (_serial->available()) _serial->read();
}
void ModbusClient::begin(uint32_t baud, uint8_t /*addr*/) {
_baud = baud;
_serial = &Serial2;
_serial->begin(_baud, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
Serial.printf("[MOD] RS485 init: %d baud, 8N1, RX=%d TX=%d\n",
_baud, PIN_RS485_RX, PIN_RS485_TX);
}
void ModbusClient::setBaudRate(uint32_t baud) {
_baud = baud;
_serial->end();
_serial->begin(_baud, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
Serial.printf("[MOD] Baudrate zmenen na: %d\n", _baud);
}
ModbusStatus ModbusClient::readRegisters(uint8_t slaveAddr, uint16_t startReg,
uint8_t count, uint16_t* out) {
if (!_serial || count == 0 || count > 64) return ModbusStatus::FRAME_ERROR;
uint8_t req[8];
req[0] = slaveAddr;
req[1] = 0x03;
req[2] = (startReg >> 8) & 0xFF;
req[3] = startReg & 0xFF;
req[4] = 0x00;
req[5] = count;
uint16_t c = crc16(req, 6);
req[6] = c & 0xFF;
req[7] = (c >> 8) & 0xFF;
memcpy(lastTx, req, 8);
lastTxLen = 8;
flushRx();
_serial->write(req, 8);
_serial->flush();
uint8_t expectedLen = 3 + count * 2 + 2;
uint8_t buf[160];
uint8_t rxLen = 0;
uint32_t t0 = millis();
while (rxLen < expectedLen && (millis() - t0 < TIMEOUT_MS)) {
if (_serial->available()) buf[rxLen++] = _serial->read();
yield();
}
uint8_t copyLen = (rxLen < sizeof(lastRx)) ? rxLen : sizeof(lastRx);
memcpy(lastRx, buf, copyLen);
lastRxLen = copyLen;
if (rxLen < expectedLen) {
lastResult = ModbusStatus::TIMEOUT;
return ModbusStatus::TIMEOUT;
}
uint16_t rxCrc = (uint16_t)buf[rxLen-1] << 8 | buf[rxLen-2];
uint16_t calcCrc = crc16(buf, rxLen - 2);
if (rxCrc != calcCrc) {
lastResult = ModbusStatus::CRC_ERROR;
return ModbusStatus::CRC_ERROR;
}
if (buf[1] & 0x80) {
lastResult = ModbusStatus::EXCEPTION;
return ModbusStatus::EXCEPTION;
}
lastResult = ModbusStatus::OK;
for (uint8_t i = 0; i < count; i++) {
out[i] = (uint16_t)buf[3 + i*2] << 8 | buf[4 + i*2];
}
return ModbusStatus::OK;
}
ModbusStatus ModbusClient::writeRegister(uint8_t slaveAddr, uint16_t reg, uint16_t value) {
if (!_serial) return ModbusStatus::FRAME_ERROR;
uint8_t req[8];
req[0] = slaveAddr;
req[1] = 0x06;
req[2] = (reg >> 8) & 0xFF;
req[3] = reg & 0xFF;
req[4] = (value >> 8) & 0xFF;
req[5] = value & 0xFF;
uint16_t c = crc16(req, 6);
req[6] = c & 0xFF;
req[7] = (c >> 8) & 0xFF;
memcpy(lastTx, req, 8);
lastTxLen = 8;
flushRx();
_serial->write(req, 8);
_serial->flush();
// Pri broadcast adrese 0x00 nepriche odpoved
if (slaveAddr == 0x00) {
lastRxLen = 0;
lastResult = ModbusStatus::OK;
delay(50);
return ModbusStatus::OK;
}
uint8_t buf[8];
uint8_t rxLen = 0;
uint32_t t0 = millis();
while (rxLen < 8 && (millis() - t0 < TIMEOUT_MS)) {
if (_serial->available()) buf[rxLen++] = _serial->read();
yield();
}
memcpy(lastRx, buf, rxLen);
lastRxLen = rxLen;
if (rxLen < 8) { lastResult = ModbusStatus::TIMEOUT; return ModbusStatus::TIMEOUT; }
uint16_t rxCrc = (uint16_t)buf[7] << 8 | buf[6];
uint16_t calcCrc = crc16(buf, 6);
if (rxCrc != calcCrc) { lastResult = ModbusStatus::CRC_ERROR; return ModbusStatus::CRC_ERROR; }
lastResult = ModbusStatus::OK;
return ModbusStatus::OK;
}
const char* ModbusClient::statusStr(ModbusStatus s) {
switch (s) {
case ModbusStatus::OK: return "OK";
case ModbusStatus::TIMEOUT: return "TIMEOUT";
case ModbusStatus::CRC_ERROR: return "CRC_ERROR";
case ModbusStatus::EXCEPTION: return "EXCEPTION";
case ModbusStatus::FRAME_ERROR: return "FRAME_ERROR";
default: return "UNKNOWN";
}
}

View File

@ -0,0 +1,54 @@
#ifndef RL_MB_CLIENT_H
#define RL_MB_CLIENT_H
/*
* mb_client.h Modbus RTU klient pres RS485 / HardwareSerial
*
* FC03 Read Holding Registers
* FC06 Write Single Register
*
* DE pin rizen hardwarove nevyzaduje softwarove prepinani.
*/
#include <Arduino.h>
#include "cfg.h"
enum class ModbusStatus : uint8_t {
OK = 0,
TIMEOUT,
CRC_ERROR,
EXCEPTION,
FRAME_ERROR
};
class ModbusClient {
public:
ModbusClient() = default;
void begin(uint32_t baud = 9600, uint8_t addr = 1);
ModbusStatus readRegisters(uint8_t slaveAddr, uint16_t startReg,
uint8_t count, uint16_t* out);
ModbusStatus writeRegister(uint8_t slaveAddr, uint16_t reg, uint16_t value);
void setBaudRate(uint32_t baud);
const char* statusStr(ModbusStatus s);
// ── Posledni TX/RX frame ────────────────────────────────────────────
uint8_t lastTx[16];
uint8_t lastTxLen = 0;
uint8_t lastRx[80];
uint8_t lastRxLen = 0;
ModbusStatus lastResult = ModbusStatus::OK;
private:
HardwareSerial* _serial = nullptr;
uint32_t _baud = 9600;
static const uint16_t TIMEOUT_MS = 500;
uint16_t crc16(const uint8_t* data, uint8_t len);
void flushRx();
};
#endif // RL_MB_CLIENT_H

114
SW/ESPlan_RS485_Relay/relay.cpp Executable file
View File

@ -0,0 +1,114 @@
/*
* relay.cpp Ovladani RS485 rele modulu (8 / 16 kanalu)
*/
#include "relay.h"
static bool sendCmd(ModbusClient& mb, RelayState& st, uint8_t addr,
uint16_t reg, uint16_t val, const String& actionDesc) {
ModbusStatus s = mb.writeRegister(addr, reg, val);
st.lastCmdMs = millis();
st.lastAction = actionDesc;
if (s == ModbusStatus::OK) {
st.commError = false;
st.errorMsg = "";
Serial.printf("[REL] %s : OK\n", actionDesc.c_str());
return true;
} else {
st.commError = true;
st.errorMsg = mb.statusStr(s);
Serial.printf("[REL] %s : %s\n", actionDesc.c_str(), mb.statusStr(s));
return false;
}
}
static inline bool validCh(uint8_t channel, const RelayConfig& cfg) {
return (channel >= 1 && channel <= cfg.channelCount);
}
bool relaySet(ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel, bool on) {
if (!validCh(channel, cfg)) return false;
uint16_t reg = REG_CHANNEL_BASE + (channel - 1);
uint16_t val = makeVal(on ? FN_OPEN : FN_CLOSE);
String d = String("ch") + channel + (on ? " ON" : " OFF");
bool ok = sendCmd(mb, st, cfg.modbusAddr, reg, val, d);
if (ok) st.channels[channel - 1] = on;
return ok;
}
bool relayToggle(ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel) {
if (!validCh(channel, cfg)) return false;
uint16_t reg = REG_CHANNEL_BASE + (channel - 1);
uint16_t val = makeVal(FN_TOGGLE);
String d = String("ch") + channel + " TOGGLE";
bool ok = sendCmd(mb, st, cfg.modbusAddr, reg, val, d);
if (ok) st.channels[channel - 1] = !st.channels[channel - 1];
return ok;
}
bool relayLatch(ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel) {
if (!validCh(channel, cfg)) return false;
uint16_t reg = REG_CHANNEL_BASE + (channel - 1);
uint16_t val = makeVal(FN_LATCH);
String d = String("ch") + channel + " LATCH";
bool ok = sendCmd(mb, st, cfg.modbusAddr, reg, val, d);
if (ok) {
// Inter-locking: jen tento kanal ON, ostatni OFF
for (uint8_t i = 0; i < cfg.channelCount; i++) {
st.channels[i] = (i == (channel - 1));
}
}
return ok;
}
bool relayMomentary(ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel) {
if (!validCh(channel, cfg)) return false;
uint16_t reg = REG_CHANNEL_BASE + (channel - 1);
uint16_t val = makeVal(FN_MOMENTARY);
String d = String("ch") + channel + " MOMENTARY (1s)";
// Pri momentary se rele po 1s vrati do OFF. SW shadow nemenime
// dalsi periodicky FC03 sync stav doplni.
return sendCmd(mb, st, cfg.modbusAddr, reg, val, d);
}
bool relayDelay(ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel, uint8_t seconds) {
if (!validCh(channel, cfg)) return false;
uint16_t reg = REG_CHANNEL_BASE + (channel - 1);
uint16_t val = makeVal(FN_DELAY, seconds);
String d = String("ch") + channel + " DELAY " + seconds + "s";
bool ok = sendCmd(mb, st, cfg.modbusAddr, reg, val, d);
if (ok) st.channels[channel - 1] = true; // okamzite ON, pak FC03 zjisti OFF
return ok;
}
bool relayAll(ModbusClient& mb, RelayState& st, const RelayConfig& cfg, bool on) {
uint16_t val = makeVal(on ? FN_ALL_ON : FN_ALL_OFF);
String d = on ? "ALL ON" : "ALL OFF";
bool ok = sendCmd(mb, st, cfg.modbusAddr, REG_ALL_CHANNELS, val, d);
if (ok) {
for (uint8_t i = 0; i < cfg.channelCount; i++) st.channels[i] = on;
}
return ok;
}
bool relayReadAll(ModbusClient& mb, RelayState& st, const RelayConfig& cfg) {
uint16_t r[MAX_CHANNEL_COUNT] = {0};
ModbusStatus s = mb.readRegisters(cfg.modbusAddr,
REG_CHANNEL_BASE, cfg.channelCount, r);
st.lastUpdateMs = millis();
if (s != ModbusStatus::OK) {
Serial.printf("[REL] Cteni stavu: %s (shadow zachovan)\n",
mb.statusStr(s));
return false;
}
// Dle datasheetu: 0x0001 = open(ON), 0x0000 = close(OFF)
for (uint8_t i = 0; i < cfg.channelCount; i++) {
st.channels[i] = (r[i] != 0);
}
return true;
}

40
SW/ESPlan_RS485_Relay/relay.h Executable file
View File

@ -0,0 +1,40 @@
#ifndef RL_RELAY_H
#define RL_RELAY_H
/*
* relay.h Ovladani RS485 rele modulu (8 / 16 kanalu)
*
* Vsechny funkce vraci true pri OK odpovedi z modulu. SW stav
* v RelayState.channels[] se aktualizuje pouze pri OK.
*/
#include "cfg.h"
#include "mb_client.h"
// Open / Close (sepne / rozepne)
bool relaySet (ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel /*1..N*/, bool on);
// Toggle (self-locking) prepne stav
bool relayToggle (ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel);
// Latch (inter-locking) jen tento kanal ON, ostatni OFF
bool relayLatch (ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel);
// Momentary (non-locking) 1s puls
bool relayMomentary(ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel);
// Delay sepne na zadany pocet sekund (0-255), pak automaticky rozepne
bool relayDelay (ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
uint8_t channel, uint8_t seconds);
// Vsechny kanaly ON / OFF
bool relayAll (ModbusClient& mb, RelayState& st, const RelayConfig& cfg,
bool on);
// Cteni stavu vsech aktivnich kanalu pres FC03
bool relayReadAll (ModbusClient& mb, RelayState& st, const RelayConfig& cfg);
#endif // RL_RELAY_H

View File

@ -0,0 +1,661 @@
/*
* ws_server.cpp HTTP webserver pro RS485 rele modul (8 / 16 kanalu)
*/
#include "ws_server.h"
#include <ETH.h>
// ─── Globalni pointery ────────────────────────────────────────────────────────
static WebServer* gServer = nullptr;
static RelayState* gState = nullptr;
static RelayConfig* gCfg = nullptr;
static Preferences* gPrefs = nullptr;
static ModbusClient* gMb = nullptr;
// ─── Pomocne ─────────────────────────────────────────────────────────────────
static bool isLangCz() {
if (gServer->hasArg("lang")) {
String l = gServer->arg("lang");
return !(l == "en");
}
if (gServer->hasHeader("Cookie")) {
String c = gServer->header("Cookie");
if (c.indexOf("lang=en") >= 0) return false;
}
return true;
}
static void sendLangCookie(bool cz) {
gServer->sendHeader("Set-Cookie",
cz ? "lang=cs; Path=/; Max-Age=31536000"
: "lang=en; Path=/; Max-Age=31536000");
}
// ─── CSS ─────────────────────────────────────────────────────────────────────
static const char CSS[] PROGMEM = R"CSS(
:root{
--bg:#0d1117;--bg2:#161b22;--bg3:#21262d;
--border:#30363d;--border2:#484f58;
--text:#e6edf3;--text2:#8b949e;--text3:#6e7681;
--accent:#58a6ff;--accent2:#1f6feb;
--green:#3fb950;--red:#f85149;--yellow:#d29922;--purple:#a371f7;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;
min-height:100vh;font-size:14px;line-height:1.5}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
.topbar{background:var(--bg2);border-bottom:1px solid var(--border);
padding:0 24px;display:flex;align-items:center;height:52px;
position:sticky;top:0;z-index:100}
.logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;
color:var(--text);margin-right:32px;white-space:nowrap}
.logo-icon{width:28px;height:28px;background:#1f6feb;border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff;font-weight:700}
.nav-links{display:flex;gap:2px;flex:1}
.nav-link{padding:6px 14px;border-radius:6px;font-size:13px;
color:var(--text2);transition:background .15s,color .15s;white-space:nowrap}
.nav-link:hover{background:var(--bg3);color:var(--text);text-decoration:none}
.nav-link.active{background:var(--accent2);color:#fff}
.lang-btn{margin-left:auto;padding:5px 12px;border-radius:20px;
background:var(--bg3);border:1px solid var(--border2);
color:var(--text2);font-size:12px;white-space:nowrap}
.lang-btn:hover{background:var(--border);color:var(--text);text-decoration:none}
.main{max-width:1100px;margin:0 auto;padding:28px 20px}
h1{font-size:20px;font-weight:600;margin-bottom:20px}
h2{font-size:12px;font-weight:600;color:var(--text3);text-transform:uppercase;
letter-spacing:.08em;margin:28px 0 12px;padding-bottom:8px;
border-bottom:1px solid var(--border)}
h2:first-of-type{margin-top:0}
.card{background:var(--bg2);border:1px solid var(--border);
border-radius:10px;padding:16px 20px;margin-bottom:16px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{padding:8px 12px;text-align:left;font-weight:600;font-size:11px;
color:var(--text3);text-transform:uppercase;letter-spacing:.06em;
border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.02)}
.ch-num{font-family:'Courier New',monospace;color:var(--text3);width:32px;text-align:center}
.state-on{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-weight:600;font-size:12px}
.state-off{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:12px}
.dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.dot-g{background:var(--green);box-shadow:0 0 8px var(--green)}
.dot-x{background:var(--text3)}
.err-banner{background:rgba(248,81,73,.1);border:1px solid var(--red);
border-radius:8px;padding:10px 16px;color:var(--red);font-size:13px;
margin-bottom:16px;display:flex;align-items:center;gap:8px}
.info-banner{background:rgba(88,166,255,.08);border:1px solid var(--border);
border-radius:8px;padding:12px 16px;color:var(--text2);font-size:12px;
margin-bottom:16px;line-height:1.6}
.ip-bar{display:flex;align-items:center;gap:20px;padding:10px 16px;
background:var(--bg3);border:1px solid var(--border);border-radius:8px;
font-family:'Courier New',monospace;font-size:12px;color:var(--text2);
margin-bottom:20px;flex-wrap:wrap}
.ip-lbl{color:var(--text3)}
.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;
border-radius:6px;font-size:12px;cursor:pointer;border:1px solid transparent;
font-family:inherit;transition:background .15s;text-decoration:none}
.btn:active{opacity:.8}
.btn-p{background:var(--accent2);color:#fff;border-color:var(--accent2)}
.btn-p:hover{background:#388bfd;text-decoration:none;color:#fff}
.btn-d{background:var(--bg3);color:var(--text);border-color:var(--border2)}
.btn-d:hover{background:var(--border);text-decoration:none}
.btn-w{background:transparent;color:var(--yellow);border-color:var(--yellow)}
.btn-w:hover{background:rgba(210,153,34,.1);text-decoration:none}
.btn-pp{background:transparent;color:var(--purple);border-color:var(--purple)}
.btn-pp:hover{background:rgba(163,113,247,.1);text-decoration:none;color:var(--purple)}
.btn-g{background:var(--green);color:#fff;border-color:var(--green)}
.btn-g:hover{background:#46c95a;text-decoration:none;color:#fff}
.btn-r{background:var(--red);color:#fff;border-color:var(--red)}
.btn-r:hover{background:#ff5e54;text-decoration:none;color:#fff}
.btn-sm{padding:4px 10px;font-size:11px}
.actions-row{display:flex;gap:5px;flex-wrap:wrap}
.bulk-row{display:flex;gap:12px;flex-wrap:wrap}
.fg{margin-bottom:14px}
label{display:block;font-size:13px;font-weight:500;color:var(--text2);margin-bottom:5px}
.ht{font-size:11px;color:var(--text3);margin-top:4px}
input[type=number],input[type=text],select{
background:var(--bg3);border:1px solid var(--border2);border-radius:6px;
padding:7px 10px;color:var(--text);font-size:13px;width:100%;
max-width:280px;outline:none;transition:border-color .15s;font-family:inherit}
input[type=number]{max-width:120px}
input:focus,select:focus{border-color:var(--accent)}
select option{background:var(--bg3)}
.frow{display:flex;gap:16px;flex-wrap:wrap}
.frow .fg{flex:1;min-width:200px}
.name-input{max-width:180px;font-size:12px;padding:5px 8px}
.adv-row{display:flex;align-items:end;gap:8px;flex-wrap:wrap}
.adv-row .fg{margin-bottom:0;min-width:0}
.adv-inline{display:inline-flex;align-items:center;gap:6px}
.adv-inline input[type=number]{width:80px;max-width:80px;padding:5px 8px;font-size:12px}
.smeta{font-size:12px;color:var(--text3);font-family:'Courier New',monospace}
.raw-panel{background:var(--bg2);border:1px solid var(--border);border-radius:10px;
padding:14px 20px;margin-bottom:16px;font-family:'Courier New',monospace;
font-size:12px;line-height:1.8}
.raw-tx{color:var(--yellow)}
.raw-rx{color:var(--green)}
.raw-err{color:var(--red)}
.raw-label{color:var(--text3);display:inline-block;width:28px}
.raw-status{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:600;text-transform:uppercase;margin-left:8px}
.raw-status.ok{background:rgba(63,185,80,.15);color:var(--green)}
.raw-status.err{background:rgba(248,81,73,.15);color:var(--red)}
@media(max-width:640px){
.topbar{padding:0 12px}
.logo{margin-right:12px}
.main{padding:16px 12px}
th,td{padding:7px 6px}
.actions-row{gap:3px}
.btn{padding:5px 8px;font-size:11px}
input,select{max-width:100%}
}
)CSS";
// ─── HTML head + nav ─────────────────────────────────────────────────────────
static String htmlHead(const I18nStrings& s, bool cz, const char* page) {
String langUrl = cz ? "?lang=en" : "?lang=cs";
String out;
out.reserve(3000);
out += F("<!DOCTYPE html><html lang='");
out += cz ? "cs" : "en";
out += F("'><head><meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>");
out += s.title;
out += F("</title><style>");
out += FPSTR(CSS);
out += F("</style></head><body>"
"<nav class='topbar'>"
"<div class='logo'>"
"<div class='logo-icon'>R</div>");
out += s.title;
out += F("</div><div class='nav-links'>");
out += F("<a href='/' class='nav-link");
if (strcmp(page,"status")==0) out += F(" active");
out += F("'>");
out += s.navStatus;
out += F("</a><a href='/config' class='nav-link");
if (strcmp(page,"config")==0) out += F(" active");
out += F("'>");
out += s.navConfig;
out += F("</a></div><a href='");
out += langUrl;
out += F("' class='lang-btn'>");
out += s.langSwitch;
out += F("</a></nav><div class='main'>");
return out;
}
static String htmlFoot() {
return F("</div></body></html>");
}
// ─── Stranka: ovladani ────────────────────────────────────────────────────────
static void handleRoot() {
bool cz = isLangCz();
sendLangCookie(cz);
const I18nStrings& s = getStrings(cz);
String html = htmlHead(s, cz, "status");
// IP bar
html += F("<div class='ip-bar'>"
"<span><span class='ip-lbl'>IP: </span><b>");
html += ETH.localIP().toString();
html += F("</b></span><span><span class='ip-lbl'>MAC: </span>");
html += ETH.macAddress();
html += F("</span><span><span class='ip-lbl'>Modbus: </span>");
html += gCfg->modbusAddr;
html += F(" @ ");
html += gCfg->baudRate;
html += F(" bps</span><span><span class='ip-lbl'>CH: </span>");
html += gCfg->channelCount;
html += F("</span></div>");
if (gState->commError) {
html += F("<div class='err-banner'>&#9888; ");
html += s.commError;
html += F(": ");
html += gState->errorMsg;
html += F("</div>");
}
// Tabulka kanalu
html += F("<h2>");
html += s.sectionRelays;
html += F("</h2><div class='card'><table>"
"<thead><tr><th class='ch-num'>");
html += s.colChannel;
html += F("</th><th>");
html += s.colName;
html += F("</th><th>");
html += s.colState;
html += F("</th><th>");
html += s.colCtrl;
html += F("</th></tr></thead><tbody>");
String langInp = cz ? "cs" : "en";
for (uint8_t i = 0; i < gCfg->channelCount; i++) {
bool on = gState->channels[i];
html += F("<tr><td class='ch-num'>");
html += (i + 1);
html += F("</td><td>");
html += gCfg->names[i];
html += F("</td><td id='st_");
html += i;
html += F("'>");
if (on) {
html += F("<span class='state-on'><span class='dot dot-g'></span>");
html += s.stateOn;
html += F("</span>");
} else {
html += F("<span class='state-off'><span class='dot dot-x'></span>");
html += s.stateOff;
html += F("</span>");
}
html += F("</td><td><div class='actions-row'>");
auto addBtn = [&](const char* act, const char* cls, const char* label) {
html += F("<form method='POST' action='/relay' style='display:inline'>"
"<input type='hidden' name='ch' value='");
html += (i + 1);
html += F("'><input type='hidden' name='act' value='");
html += act;
html += F("'><input type='hidden' name='lang' value='");
html += langInp;
html += F("'><button type='submit' class='btn ");
html += cls;
html += F(" btn-sm'>");
html += label;
html += F("</button></form>");
};
addBtn("on", "btn-g", s.btnOn);
addBtn("off", "btn-r", s.btnOff);
addBtn("toggle", "btn-d", s.btnToggle);
html += F("</div></td></tr>");
}
html += F("</tbody></table></div>");
// Specialni rezimy (Latch / Momentary / Delay) ako kompaktni panel
html += F("<h2>");
html += s.sectionAdvanced;
html += F("</h2><div class='card'>");
html += F("<div class='ht' style='margin-bottom:10px'>");
html += s.labelLatchHelp;
html += F(" &mdash; ");
html += s.labelMomentaryHelp;
html += F(" &mdash; ");
html += s.labelDelayHelp;
html += F("</div>");
// Latch / Momentary / Delay pro kazdy kanal radek
html += F("<table><thead><tr><th class='ch-num'>");
html += s.colChannel;
html += F("</th><th>");
html += s.colName;
html += F("</th><th>");
html += s.colCtrl;
html += F("</th></tr></thead><tbody>");
for (uint8_t i = 0; i < gCfg->channelCount; i++) {
html += F("<tr><td class='ch-num'>");
html += (i + 1);
html += F("</td><td>");
html += gCfg->names[i];
html += F("</td><td><div class='actions-row'>");
// Latch
html += F("<form method='POST' action='/relay' style='display:inline'>"
"<input type='hidden' name='ch' value='");
html += (i + 1);
html += F("'><input type='hidden' name='act' value='latch'>"
"<input type='hidden' name='lang' value='");
html += langInp;
html += F("'><button type='submit' class='btn btn-w btn-sm'>");
html += s.btnLatch;
html += F("</button></form>");
// Momentary (Pulse)
html += F("<form method='POST' action='/relay' style='display:inline'>"
"<input type='hidden' name='ch' value='");
html += (i + 1);
html += F("'><input type='hidden' name='act' value='momentary'>"
"<input type='hidden' name='lang' value='");
html += langInp;
html += F("'><button type='submit' class='btn btn-pp btn-sm'>");
html += s.btnMomentary;
html += F("</button></form>");
// Delay s inputem sekund
html += F("<form method='POST' action='/relay' style='display:inline'>"
"<input type='hidden' name='ch' value='");
html += (i + 1);
html += F("'><input type='hidden' name='act' value='delay'>"
"<input type='hidden' name='lang' value='");
html += langInp;
html += F("'><span class='adv-inline'>"
"<input type='number' name='sec' min='0' max='255' value='");
html += gCfg->defaultDelay;
html += F("'><button type='submit' class='btn btn-w btn-sm'>");
html += s.btnDelay;
html += F(" ");
html += s.labelDelaySec;
html += F("</button></span></form>");
html += F("</div></td></tr>");
}
html += F("</tbody></table>");
html += F("</div>");
// Hromadne ovladani
html += F("<h2>");
html += s.sectionActions;
html += F("</h2><div class='card'><div class='bulk-row'>"
"<form method='POST' action='/relay'>"
"<input type='hidden' name='act' value='all_on'>"
"<input type='hidden' name='lang' value='");
html += langInp;
html += F("'><button type='submit' class='btn btn-g'>");
html += s.btnAllOn;
html += F("</button></form>"
"<form method='POST' action='/relay'>"
"<input type='hidden' name='act' value='all_off'>"
"<input type='hidden' name='lang' value='");
html += langInp;
html += F("'><button type='submit' class='btn btn-r'>");
html += s.btnAllOff;
html += F("</button></form>"
"</div>");
if (gState->lastAction.length() > 0) {
html += F("<div class='ht' style='margin-top:10px'>");
html += s.lastAction;
html += F(": <span class='smeta'>");
html += gState->lastAction;
html += F("</span></div>");
}
html += F("</div>");
// Raw RS485 panel
html += F("<h2>");
html += s.sectionRaw;
html += F("</h2><div class='raw-panel'>"
"<div><span class='raw-label'>TX:</span> <span class='raw-tx' id='rawTx'>...</span>"
" <span class='raw-status' id='rawSt'></span></div>"
"<div><span class='raw-label'>RX:</span> <span class='raw-rx' id='rawRx'>...</span></div>"
"</div>");
// JS: refresh stavu + raw panelu
html += F("<script>"
"var SON='");
html += s.stateOn;
html += F("',SOFF='");
html += s.stateOff;
html += F("';"
"function setSt(i,on){"
"var e=document.getElementById('st_'+i);"
"if(!e)return;"
"e.innerHTML=on?"
"(\"<span class='state-on'><span class='dot dot-g'></span>\"+SON+\"</span>\"):"
"(\"<span class='state-off'><span class='dot dot-x'></span>\"+SOFF+\"</span>\");"
"}"
"function upd(){"
"fetch('/api/state').then(function(r){return r.json()}).then(function(d){"
"if(d.ch){for(var i=0;i<d.ch.length;i++)setSt(i,d.ch[i]);}"
"}).catch(function(){});"
"fetch('/api/raw').then(function(r){return r.json()}).then(function(d){"
"var tx=document.getElementById('rawTx');"
"var rx=document.getElementById('rawRx');"
"var st=document.getElementById('rawSt');"
"if(tx)tx.textContent=d.tx||'-';"
"if(rx){rx.textContent=d.rx||'-';"
"rx.className=(d.s==='OK')?'raw-rx':'raw-err';}"
"if(st){st.textContent=d.s;"
"st.className='raw-status '+(d.s==='OK'?'ok':'err');}"
"}).catch(function(){});"
"}"
"upd();setInterval(upd,3000);"
"</script>");
html += htmlFoot();
gServer->send(200, "text/html; charset=UTF-8", html);
}
// ─── POST /relay ─────────────────────────────────────────────────────────────
static void handleRelay() {
bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true;
String act = gServer->arg("act");
uint8_t ch = (uint8_t)gServer->arg("ch").toInt();
if (act == "on" && ch >= 1 && ch <= gCfg->channelCount) {
relaySet(*gMb, *gState, *gCfg, ch, true);
} else if (act == "off" && ch >= 1 && ch <= gCfg->channelCount) {
relaySet(*gMb, *gState, *gCfg, ch, false);
} else if (act == "toggle" && ch >= 1 && ch <= gCfg->channelCount) {
relayToggle(*gMb, *gState, *gCfg, ch);
} else if (act == "latch" && ch >= 1 && ch <= gCfg->channelCount) {
relayLatch(*gMb, *gState, *gCfg, ch);
} else if (act == "momentary" && ch >= 1 && ch <= gCfg->channelCount) {
relayMomentary(*gMb, *gState, *gCfg, ch);
} else if (act == "delay" && ch >= 1 && ch <= gCfg->channelCount) {
uint8_t sec = (uint8_t)constrain(gServer->arg("sec").toInt(), 0, 255);
relayDelay(*gMb, *gState, *gCfg, ch, sec);
} else if (act == "all_on") {
relayAll(*gMb, *gState, *gCfg, true);
} else if (act == "all_off") {
relayAll(*gMb, *gState, *gCfg, false);
}
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/?lang=cs" : "/?lang=en");
gServer->send(303);
}
// ─── GET /config ──────────────────────────────────────────────────────────────
static void handleConfig() {
bool cz = isLangCz();
sendLangCookie(cz);
const I18nStrings& s = getStrings(cz);
String html = htmlHead(s, cz, "config");
html += F("<h1>");
html += s.pageConfigTitle;
html += F("</h1>");
// Info banner o DIP
html += F("<div class='info-banner'>");
html += s.infoDip;
html += F("</div>");
html += F("<form method='POST' action='/config'>");
html += F("<input type='hidden' name='lang' value='");
html += cz ? "cs" : "en";
html += F("'>");
// Komunikace
html += F("<h2>");
html += s.sectionComm;
html += F("</h2><div class='card'><div class='frow'>");
html += F("<div class='fg'><label>");
html += s.cfgAddr;
html += F("</label><input type='number' name='addr' min='0' max='47' value='");
html += gCfg->modbusAddr;
html += F("'><div class='ht'>");
html += s.cfgAddrHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgBaud;
html += F("</label><select name='baud'>");
const uint32_t bauds[] = {2400, 4800, 9600, 19200};
for (auto b : bauds) {
html += F("<option value='");
html += b;
html += F("'");
if (b == gCfg->baudRate) html += F(" selected");
html += F(">");
html += b;
html += F(" bps</option>");
}
html += F("</select><div class='ht'>");
html += s.cfgBaudHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgChannels;
html += F("</label><select name='chCount'>"
"<option value='8'");
if (gCfg->channelCount == 8) html += F(" selected");
html += F(">8</option><option value='16'");
if (gCfg->channelCount == 16) html += F(" selected");
html += F(">16</option></select><div class='ht'>");
html += s.cfgChannelsHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgDefDelay;
html += F("</label><input type='number' name='defDelay' min='0' max='255' value='");
html += gCfg->defaultDelay;
html += F("'><div class='ht'>");
html += s.cfgDefDelayHelp;
html += F("</div></div>");
html += F("</div></div>");
// Nazvy kanalu
html += F("<h2>");
html += s.sectionNames;
html += F("</h2><div class='card'><div class='ht' style='margin-bottom:10px'>");
html += s.cfgNameHelp;
html += F("</div><div class='frow'>");
for (uint8_t i = 0; i < gCfg->channelCount; i++) {
html += F("<div class='fg' style='min-width:170px'><label>#");
html += (i + 1);
html += F("</label><input class='name-input' type='text' name='n");
html += i;
html += F("' maxlength='15' value='");
html += gCfg->names[i];
html += F("'></div>");
}
html += F("</div></div>");
html += F("<div style='margin-top:8px'>"
"<button type='submit' class='btn btn-p'>");
html += s.btnSave;
html += F("</button></div></form>");
html += htmlFoot();
gServer->send(200, "text/html; charset=UTF-8", html);
}
// ─── POST /config ─────────────────────────────────────────────────────────────
static void handleConfigPost() {
bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true;
if (gServer->hasArg("addr")) {
gCfg->modbusAddr = (uint8_t)constrain(gServer->arg("addr").toInt(),
MODBUS_ADDR_MIN, MODBUS_ADDR_MAX);
}
if (gServer->hasArg("baud")) {
uint32_t b = (uint32_t)gServer->arg("baud").toInt();
if (b != gCfg->baudRate) { gCfg->baudRate = b; gMb->setBaudRate(b); }
}
if (gServer->hasArg("chCount")) {
uint8_t c = (uint8_t)gServer->arg("chCount").toInt();
gCfg->channelCount = (c == 16) ? 16 : 8;
}
if (gServer->hasArg("defDelay")) {
gCfg->defaultDelay = (uint8_t)constrain(gServer->arg("defDelay").toInt(), 0, 255);
}
for (uint8_t i = 0; i < MAX_CHANNEL_COUNT; i++) {
String key = String("n") + i;
if (gServer->hasArg(key.c_str())) {
String v = gServer->arg(key.c_str());
strncpy(gCfg->names[i], v.c_str(), 15);
gCfg->names[i][15] = 0;
}
}
gPrefs->begin("rs485rel", false);
saveConfig(*gPrefs, *gCfg);
gPrefs->end();
Serial.println(F("[CFG] Konfigurace ulozena."));
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en");
gServer->send(303);
}
// ─── GET /api/state ──────────────────────────────────────────────────────────
static void handleApiState() {
String json = "{";
json += "\"ts\":" + String(gState->lastUpdateMs);
json += ",\"err\":" + String(gState->commError ? "true" : "false");
json += ",\"ch\":[";
for (uint8_t i = 0; i < gCfg->channelCount; i++) {
if (i > 0) json += ",";
json += gState->channels[i] ? "true" : "false";
}
json += "],\"last\":\"" + gState->lastAction + "\"";
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── GET /api/raw ────────────────────────────────────────────────────────────
static String hexBuf(const uint8_t* buf, uint8_t len) {
String s;
s.reserve(len * 3);
for (uint8_t i = 0; i < len; i++) {
char h[4];
snprintf(h, sizeof(h), "%02X ", buf[i]);
s += h;
}
if (s.length() > 0) s.remove(s.length() - 1);
return s;
}
static void handleApiRaw() {
String json = "{";
json += "\"tx\":\"" + hexBuf(gMb->lastTx, gMb->lastTxLen) + "\"";
json += ",\"rx\":\"" + hexBuf(gMb->lastRx, gMb->lastRxLen) + "\"";
json += ",\"s\":\"" + String(gMb->statusStr(gMb->lastResult)) + "\"";
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── Inicializace ─────────────────────────────────────────────────────────────
void webServerBegin(RelayState& state, RelayConfig& cfg,
Preferences& prefs, ModbusClient& mb) {
gState = &state;
gCfg = &cfg;
gPrefs = &prefs;
gMb = &mb;
gServer = new WebServer(80);
static const char* hdrs[] = {"Cookie"};
gServer->collectHeaders(hdrs, 1);
gServer->on("/", HTTP_GET, handleRoot);
gServer->on("/relay", HTTP_POST, handleRelay);
gServer->on("/config", HTTP_GET, handleConfig);
gServer->on("/config", HTTP_POST, handleConfigPost);
gServer->on("/api/state", HTTP_GET, handleApiState);
gServer->on("/api/raw", HTTP_GET, handleApiRaw);
gServer->onNotFound([]() {
gServer->send(404, "text/plain", "Not found");
});
gServer->begin();
Serial.println(F("[WEB] HTTP server spusten na portu 80"));
}
void webServerHandle() {
if (gServer) gServer->handleClient();
}

View File

@ -0,0 +1,28 @@
#ifndef RL_WS_SERVER_H
#define RL_WS_SERVER_H
/*
* ws_server.h HTTP webserver pro rele modul
*
* Stranky:
* GET / Ovladani rele (8 kanalu + hromadne)
* GET /config Konfigurace
* POST /config Ulozeni konfigurace
* POST /relay Akce: on/off/toggle/all_on/all_off
* POST /action Zmena adresy modulu
* GET /api/state JSON se stavem
* GET /api/raw JSON s poslednim RS485 TX/RX
*/
#include <WebServer.h>
#include <Preferences.h>
#include "cfg.h"
#include "mb_client.h"
#include "relay.h"
#include "i18n_str.h"
void webServerBegin(RelayState& state, RelayConfig& cfg,
Preferences& prefs, ModbusClient& mb);
void webServerHandle();
#endif // RL_WS_SERVER_H

View File

@ -0,0 +1,125 @@
/*
* ESPlan_WeatherStation.ino
* RS-FSXCS-N01-* Ultrazvuková meteorologická stanice
*
* Hardware : LaskaKit ESPlan (ESP32 + LAN8720)
* Senzor : RS-FSXCS-N01-* (Modbus RTU / RS485)
* Komunikace: pouze ETH (LAN8720)
*
* Soubory projektu:
* cfg.h HW konstanty, registry, datové struktury
* mb_client.h/cpp Modbus RTU klient (FC03, FC06)
* snr.h/cpp Detekce variant a čtení snímače
* i18n_str.h Překlady CZ / EN
* ws_server.h/cpp HTTP webserver (stav + konfigurace)
*/
#include <Arduino.h>
#include <WiFi.h> // nutné pro WiFiEvent_t a WiFi.onEvent
#include <ETH.h>
#include <Preferences.h>
#include "cfg.h"
#include "mb_client.h"
#include "snr.h"
#include "ws_server.h"
// ─── Globální objekty ─────────────────────────────────────────────────────────
Preferences prefs;
SensorData sensorData;
SensorConfig sensorConfig;
ModbusClient modbusClient;
static bool ethGotIP = false;
static uint32_t lastPollMs = 0;
// ─── ETH události ─────────────────────────────────────────────────────────────
void onEthEvent(WiFiEvent_t event) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
Serial.println(F("[ETH] Inicializace..."));
ETH.setHostname("espplan-weather");
break;
case ARDUINO_EVENT_ETH_CONNECTED:
Serial.println(F("[ETH] Pripojen (fyzicka vrstva)"));
break;
case ARDUINO_EVENT_ETH_GOT_IP:
Serial.print(F("[ETH] IP: "));
Serial.println(ETH.localIP());
ethGotIP = true;
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
Serial.println(F("[ETH] Odpojeno"));
ethGotIP = false;
break;
case ARDUINO_EVENT_ETH_STOP:
Serial.println(F("[ETH] Zastaveno"));
break;
default:
break;
}
}
// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println(F("\n=== ESPlan Weather Station ==="));
Serial.println(F(" RS-FSXCS-N01-* Modbus RTU "));
Serial.println(F("=============================="));
// Načtení konfigurace z NVS
prefs.begin("weather", true);
loadConfig(prefs, sensorConfig);
prefs.end();
Serial.printf("[CFG] Modbus addr=%d baud=%d poll=%ds\n",
sensorConfig.modbusAddr,
sensorConfig.baudRate,
sensorConfig.pollInterval);
// RS485 / Modbus
modbusClient.begin(sensorConfig.baudRate, sensorConfig.modbusAddr);
// ETH
WiFi.onEvent(onEthEvent);
ETH.begin(ETH_PHY_TYPE,
ETH_PHY_ADDR,
ETH_PHY_MDC,
ETH_PHY_MDIO,
ETH_PHY_POWER,
ETH_CLK_MODE);
// Čekáme na IP (max 10 s)
uint32_t t0 = millis();
while (!ethGotIP && (millis() - t0 < 10000)) {
delay(100);
}
if (!ethGotIP) {
Serial.println(F("[ETH] VAROVANI: IP neziskano web nebude dostupny!"));
}
// Webserver
webServerBegin(sensorData, sensorConfig, prefs, modbusClient);
// První detekce
Serial.println(F("[SNS] Detekuji senzory..."));
detectSensors(modbusClient, sensorData, sensorConfig);
printSensorStatus(sensorData);
Serial.println(F("[SYS] Inicializace dokoncena."));
}
// ─── Loop ─────────────────────────────────────────────────────────────────────
void loop() {
webServerHandle();
uint32_t now = millis();
if (now - lastPollMs >= (uint32_t)sensorConfig.pollInterval * 1000UL) {
lastPollMs = now;
readAllSensors(modbusClient, sensorData, sensorConfig);
}
delay(5);
}

140
SW/ESPlan_WeatherStation/cfg.h Executable file
View File

@ -0,0 +1,140 @@
#ifndef WS_CFG_H
#define WS_CFG_H
/*
* cfg.h Konfigurace hardwaru a datové struktury
*
* POZOR: Konfigurace HW pinů je pevná (nelze měnit přes web).
* Přes webové rozhraní lze měnit pouze komunikační parametry
* a kalibrační registry snímače.
*/
#include <Arduino.h>
#include <Preferences.h>
// ─── ETH (LAN8720 na ESPlan) ──────────────────────────────────────────────────
#define ETH_PHY_TYPE ETH_PHY_LAN8720
#define ETH_PHY_ADDR 0
#define ETH_PHY_MDC 23
#define ETH_PHY_MDIO 18
#define ETH_PHY_POWER -1
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
// ─── RS485 / UART ─────────────────────────────────────────────────────────────
#define PIN_RS485_RX 36
#define PIN_RS485_TX 4
// DE pin je řízen hardwarově nepotřebujeme softwarové ovládání
// ─── Modbus výchozí hodnoty ───────────────────────────────────────────────────
#define DEFAULT_MODBUS_ADDR 1
#define DEFAULT_BAUD_RATE 4800
#define DEFAULT_POLL_INTERVAL 5 // sekundy
// ─── Modbus registry snímače (RS-FSXCS-N01-*) ────────────────────────────────
// Čtecí registry (FC03 / FC04), base address 500 (0x01F4)
#define REG_WIND_SPEED 500
#define REG_WIND_FORCE 501
#define REG_WIND_DIR_8 502
#define REG_WIND_DIR_360 503
#define REG_HUMIDITY 504
#define REG_TEMPERATURE 505
#define REG_NOISE 506
#define REG_PM25_CO2 507
#define REG_PM10_CO2 508
#define REG_PRESSURE 509
#define REG_LUX_HIGH 510
#define REG_LUX_LOW 511
#define REG_LUX_100 512
#define REG_RAINFALL 513
#define REG_COMPASS 514
#define REG_SOLAR 515
// Kalibrační / konfigurační registry (FC06)
#define REG_WIND_DIR_OFFSET 0x6000
#define REG_WIND_ZERO 0x6001
#define REG_RAIN_ZERO 0x6002
#define REG_RAIN_SENS 0x6003
// ─── Flags přítomnosti senzorů ────────────────────────────────────────────────
struct SensorPresence {
bool wind = false;
bool tempHum = false;
bool pressure = false;
bool noise = false;
bool pm25 = false;
bool pm10 = false;
bool co2 = false;
bool light = false;
bool rainfall = false;
bool compass = false;
bool solar = false;
};
// ─── Naměřené hodnoty ─────────────────────────────────────────────────────────
struct SensorData {
SensorPresence present;
float windSpeed = 0.0f;
uint16_t windForce = 0;
uint8_t windDir8 = 0;
uint16_t windDir360 = 0;
float temperature = 0.0f;
float humidity = 0.0f;
float pressure = 0.0f;
float noise = 0.0f;
uint16_t pm25 = 0;
uint16_t pm10 = 0;
uint16_t co2 = 0;
uint32_t luxFull = 0;
uint32_t lux100 = 0;
float rainfall = 0.0f;
float compass = 0.0f;
uint16_t solar = 0;
uint32_t lastUpdateMs = 0;
bool commError = false;
String errorMsg = "";
};
// ─── Konfigurace snímače (ukládá se do NVS) ───────────────────────────────────
struct SensorConfig {
uint8_t modbusAddr = DEFAULT_MODBUS_ADDR;
uint32_t baudRate = DEFAULT_BAUD_RATE;
uint8_t pollInterval = DEFAULT_POLL_INTERVAL;
uint8_t windDirOffset = 0;
uint8_t rainSensitivity = 0x11;
uint8_t slot507Mode = 0; // 0=auto, 1=PM, 2=CO2
uint8_t slot508Mode = 0;
};
// ─── NVS load / save ──────────────────────────────────────────────────────────
inline void loadConfig(Preferences &p, SensorConfig &c) {
c.modbusAddr = p.getUChar("mbAddr", DEFAULT_MODBUS_ADDR);
c.baudRate = p.getUInt ("baud", DEFAULT_BAUD_RATE);
c.pollInterval = p.getUChar("poll", DEFAULT_POLL_INTERVAL);
c.windDirOffset = p.getUChar("wdOffset", 0);
c.rainSensitivity = p.getUChar("rainSens", 0x11);
c.slot507Mode = p.getUChar("slot507", 0);
c.slot508Mode = p.getUChar("slot508", 0);
}
inline void saveConfig(Preferences &p, const SensorConfig &c) {
p.putUChar("mbAddr", c.modbusAddr);
p.putUInt ("baud", c.baudRate);
p.putUChar("poll", c.pollInterval);
p.putUChar("wdOffset", c.windDirOffset);
p.putUChar("rainSens", c.rainSensitivity);
p.putUChar("slot507", c.slot507Mode);
p.putUChar("slot508", c.slot508Mode);
}
inline const char* windDir8Name(uint8_t d) {
static const char* names[] = {"N","NE","E","SE","S","SW","W","NW"};
return (d < 8) ? names[d] : "?";
}
#endif // WS_CFG_H

View File

@ -0,0 +1,244 @@
#ifndef WS_I18N_STR_H
#define WS_I18N_STR_H
/*
* i18n_str.h Překlady webového rozhraní (CZ / EN)
*/
#include <Arduino.h>
struct I18nStrings {
// Navigace
const char* title;
const char* navStatus;
const char* navConfig;
const char* langSwitch;
// Stavová stránka
const char* pageStatusTitle;
const char* sectionSensors;
const char* sectionValues;
const char* colSensor;
const char* colPresent;
const char* colValue;
const char* colUnit;
const char* yes;
const char* no;
const char* lastUpdate;
const char* noData;
const char* commError;
const char* btnRefresh;
const char* btnDetect;
const char* autoRefresh;
// Názvy senzorů
const char* snsWind;
const char* snsWindSpeed;
const char* snsWindForce;
const char* snsWindDir;
const char* snsWindDir360;
const char* snsTempHum;
const char* snsTemp;
const char* snsHum;
const char* snsPressure;
const char* snsNoise;
const char* snsPM25;
const char* snsPM10;
const char* snsCO2;
const char* snsLight;
const char* snsRainfall;
const char* snsCompass;
const char* snsSolar;
// Konfigurace
const char* pageConfigTitle;
const char* sectionComm;
const char* cfgAddr;
const char* cfgAddrHelp;
const char* cfgBaud;
const char* cfgBaudHelp;
const char* cfgPoll;
const char* cfgPollHelp;
const char* sectionCalib;
const char* cfgWindDirOffset;
const char* cfgWindDirOffsetHelp;
const char* cfgWindDirOff0;
const char* cfgWindDirOff1;
const char* cfgRainSens;
const char* cfgRainSensHelp;
const char* cfgSlot507;
const char* cfgSlot507Help;
const char* cfgSlotAuto;
const char* cfgSlotPM;
const char* cfgSlotCO2;
const char* sectionActions;
const char* btnWindZero;
const char* btnWindZeroHelp;
const char* btnRainZero;
const char* btnRainZeroHelp;
const char* btnSave;
const char* sectionRaw;
const char* windDirNames[8];
};
// ─── Čeština ──────────────────────────────────────────────────────────────────
static const I18nStrings STR_CZ = {
"ESPlan Meteostanice",
"Stav cidel",
"Konfigurace",
"English",
"Stav a hodnoty snimacu",
"Detekce senzoru",
"Namerene hodnoty",
"Senzor",
"Pritomnost",
"Hodnota",
"Jednotka",
"&#10003; Pritomno",
"&#8212; Nepritomno",
"Posledni aktualizace",
"&mdash;",
"&#9888; Chyba komunikace",
"Obnovit",
"Znovu detekovat",
"Auto obnoveni",
"Vitr",
"Rychlost vetru",
"Sila vetru (Beaufort)",
"Smer vetru (0-7)",
"Smer vetru (0-360&deg;)",
"Teplota / Vlhkost",
"Teplota",
"Relativni vlhkost",
"Atmosfericky tlak",
"Hladina hluku",
"Prach PM2.5",
"Prach PM10",
"CO&#8322;",
"Osvit",
"Srazky",
"El. kompas",
"Celkove sol. zareni",
"Konfigurace snimacu",
"Komunikacni parametry",
"Adresa Modbus",
"Adresa snimacu na sbernici RS485 (1-247, vychozi: 1)",
"Prenosova rychlost",
"Baudrate RS485 (vychozi: 4800 bps)",
"Interval cteni",
"Interval periodickeho cteni dat v sekundach (1-60)",
"Kalibrace a nastaveni",
"Korekce smeru vetru",
"Otoceni smeru o 180 stupnu",
"Normalni (0&deg;)",
"Otoceni 180&deg;",
"Citlivost destoveho snimacu",
"Hex hodnota registru 0x6003 (vychozi: 0x11). Mensi = citlivejsi.",
"Typ snimacu v registrech 507/508",
"Auto detekce nebo rucni urceni PM2.5/PM10 nebo CO2",
"Automaticka detekce",
"PM2.5 + PM10",
"CO&#8322;",
"Akce snimacu",
"Vynulovat rychlost vetru",
"Zapise 0xAA do reg. 0x6001. Pockejte 10 s.",
"Vynulovat srazkomery",
"Zapise 0x5A do reg. 0x6002.",
"Ulozit konfiguraci",
"RS485 komunikace (raw)",
{"S", "SV", "V", "JV", "J", "JZ", "Z", "SZ"}
};
// ─── Angličtina ───────────────────────────────────────────────────────────────
static const I18nStrings STR_EN = {
"ESPlan Weather Station",
"Sensor Status",
"Configuration",
"Cesky",
"Sensor Status &amp; Readings",
"Sensor Detection",
"Live Readings",
"Sensor",
"Presence",
"Value",
"Unit",
"&#10003; Present",
"&#8212; Not present",
"Last update",
"&mdash;",
"&#9888; Communication error",
"Refresh",
"Re-detect sensors",
"Auto refresh",
"Wind",
"Wind speed",
"Wind force (Beaufort)",
"Wind direction (0-7)",
"Wind direction (0-360&deg;)",
"Temperature / Humidity",
"Temperature",
"Relative humidity",
"Atmospheric pressure",
"Noise level",
"Particulate PM2.5",
"Particulate PM10",
"CO&#8322;",
"Illuminance",
"Rainfall",
"E-compass",
"Total solar radiation",
"Sensor Configuration",
"Communication Settings",
"Modbus Address",
"Sensor address on RS485 bus (1-247, default: 1)",
"Baud Rate",
"RS485 baud rate (default: 4800 bps)",
"Poll Interval",
"Data reading interval in seconds (1-60)",
"Calibration &amp; Settings",
"Wind direction offset",
"Rotate direction by 180 degrees",
"Normal (0&deg;)",
"Rotated 180&deg;",
"Rain sensor sensitivity",
"Hex value of register 0x6003 (default: 0x11). Lower = more sensitive.",
"Sensor type in registers 507/508",
"Auto-detect or manually specify PM2.5/PM10 or CO2",
"Auto detect",
"PM2.5 + PM10",
"CO&#8322;",
"Sensor Actions",
"Zero wind speed",
"Writes 0xAA to reg 0x6001. Wait 10 s.",
"Reset rainfall counter",
"Writes 0x5A to reg 0x6002.",
"Save configuration",
"RS485 Communication (raw)",
{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
};
inline const I18nStrings& getStrings(bool isCz) {
return isCz ? STR_CZ : STR_EN;
}
#endif // WS_I18N_STR_H

View File

@ -0,0 +1,152 @@
/*
* mb_client.cpp Implementace Modbus RTU klienta
*/
#include "mb_client.h"
uint16_t ModbusClient::crc16(const uint8_t* data, uint8_t len) {
uint16_t crc = 0xFFFF;
for (uint8_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i];
for (uint8_t b = 0; b < 8; b++) {
if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001;
else crc >>= 1;
}
}
return crc;
}
void ModbusClient::flushRx() {
while (_serial->available()) _serial->read();
}
void ModbusClient::begin(uint32_t baud, uint8_t /*addr*/) {
_baud = baud;
_serial = &Serial2;
_serial->begin(_baud, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
Serial.printf("[MOD] RS485 init: %d baud, 8N1, RX=%d TX=%d\n",
_baud, PIN_RS485_RX, PIN_RS485_TX);
}
void ModbusClient::setBaudRate(uint32_t baud) {
_baud = baud;
_serial->end();
_serial->begin(_baud, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
Serial.printf("[MOD] Baudrate zmenen na: %d\n", _baud);
}
ModbusStatus ModbusClient::readRegisters(uint8_t slaveAddr, uint16_t startReg,
uint8_t count, uint16_t* out) {
if (!_serial || count == 0 || count > 64) return ModbusStatus::FRAME_ERROR;
uint8_t req[8];
req[0] = slaveAddr;
req[1] = 0x03;
req[2] = (startReg >> 8) & 0xFF;
req[3] = startReg & 0xFF;
req[4] = 0x00;
req[5] = count;
uint16_t c = crc16(req, 6);
req[6] = c & 0xFF;
req[7] = (c >> 8) & 0xFF;
// Uložit TX
memcpy(lastTx, req, 8);
lastTxLen = 8;
flushRx();
_serial->write(req, 8);
_serial->flush();
uint8_t expectedLen = 3 + count * 2 + 2;
uint8_t buf[160];
uint8_t rxLen = 0;
uint32_t t0 = millis();
while (rxLen < expectedLen && (millis() - t0 < TIMEOUT_MS)) {
if (_serial->available()) buf[rxLen++] = _serial->read();
yield();
}
// Uložit RX
uint8_t copyLen = (rxLen < sizeof(lastRx)) ? rxLen : sizeof(lastRx);
memcpy(lastRx, buf, copyLen);
lastRxLen = copyLen;
if (rxLen < expectedLen) {
lastResult = ModbusStatus::TIMEOUT;
return ModbusStatus::TIMEOUT;
}
uint16_t rxCrc = (uint16_t)buf[rxLen-1] << 8 | buf[rxLen-2];
uint16_t calcCrc = crc16(buf, rxLen - 2);
if (rxCrc != calcCrc) {
lastResult = ModbusStatus::CRC_ERROR;
return ModbusStatus::CRC_ERROR;
}
if (buf[1] & 0x80) {
lastResult = ModbusStatus::EXCEPTION;
return ModbusStatus::EXCEPTION;
}
lastResult = ModbusStatus::OK;
for (uint8_t i = 0; i < count; i++) {
out[i] = (uint16_t)buf[3 + i*2] << 8 | buf[4 + i*2];
}
return ModbusStatus::OK;
}
ModbusStatus ModbusClient::writeRegister(uint8_t slaveAddr, uint16_t reg, uint16_t value) {
if (!_serial) return ModbusStatus::FRAME_ERROR;
uint8_t req[8];
req[0] = slaveAddr;
req[1] = 0x06;
req[2] = (reg >> 8) & 0xFF;
req[3] = reg & 0xFF;
req[4] = (value >> 8) & 0xFF;
req[5] = value & 0xFF;
uint16_t c = crc16(req, 6);
req[6] = c & 0xFF;
req[7] = (c >> 8) & 0xFF;
memcpy(lastTx, req, 8);
lastTxLen = 8;
flushRx();
_serial->write(req, 8);
_serial->flush();
uint8_t buf[8];
uint8_t rxLen = 0;
uint32_t t0 = millis();
while (rxLen < 8 && (millis() - t0 < TIMEOUT_MS)) {
if (_serial->available()) buf[rxLen++] = _serial->read();
yield();
}
memcpy(lastRx, buf, rxLen);
lastRxLen = rxLen;
if (rxLen < 8) { lastResult = ModbusStatus::TIMEOUT; return ModbusStatus::TIMEOUT; }
uint16_t rxCrc = (uint16_t)buf[7] << 8 | buf[6];
uint16_t calcCrc = crc16(buf, 6);
if (rxCrc != calcCrc) { lastResult = ModbusStatus::CRC_ERROR; return ModbusStatus::CRC_ERROR; }
lastResult = ModbusStatus::OK;
return ModbusStatus::OK;
}
const char* ModbusClient::statusStr(ModbusStatus s) {
switch (s) {
case ModbusStatus::OK: return "OK";
case ModbusStatus::TIMEOUT: return "TIMEOUT";
case ModbusStatus::CRC_ERROR: return "CRC_ERROR";
case ModbusStatus::EXCEPTION: return "EXCEPTION";
case ModbusStatus::FRAME_ERROR: return "FRAME_ERROR";
default: return "UNKNOWN";
}
}

View File

@ -0,0 +1,54 @@
#ifndef WS_MB_CLIENT_H
#define WS_MB_CLIENT_H
/*
* mb_client.h Modbus RTU klient přes RS485 / HardwareSerial
*
* FC03 Read Holding Registers
* FC06 Write Single Register
*
* DE pin (direction enable) řízen HW nevyžaduje softwarové přepínání.
*/
#include <Arduino.h>
#include "cfg.h"
enum class ModbusStatus : uint8_t {
OK = 0,
TIMEOUT,
CRC_ERROR,
EXCEPTION,
FRAME_ERROR
};
class ModbusClient {
public:
ModbusClient() = default;
void begin(uint32_t baud = 4800, uint8_t addr = 1);
ModbusStatus readRegisters(uint8_t slaveAddr, uint16_t startReg,
uint8_t count, uint16_t* out);
ModbusStatus writeRegister(uint8_t slaveAddr, uint16_t reg, uint16_t value);
void setBaudRate(uint32_t baud);
const char* statusStr(ModbusStatus s);
// ── Poslední TX/RX frame ────────────────────────────────────────────
uint8_t lastTx[16];
uint8_t lastTxLen = 0;
uint8_t lastRx[80];
uint8_t lastRxLen = 0;
ModbusStatus lastResult = ModbusStatus::OK;
private:
HardwareSerial* _serial = nullptr;
uint32_t _baud = 4800;
static const uint16_t TIMEOUT_MS = 500;
uint16_t crc16(const uint8_t* data, uint8_t len);
void flushRx();
};
#endif // WS_MB_CLIENT_H

266
SW/ESPlan_WeatherStation/snr.cpp Executable file
View File

@ -0,0 +1,266 @@
/*
* snr.cpp Detekce a čtení snímače RS-FSXCS-N01-*
*
* STRATEGIE DETEKCE:
* Snímač RS-FSXCS-N01-* existuje v různých variantách. Neosazené
* senzory NEVRACÍ Modbus exception vrátí 0x0000. Proto:
*
* 1. Registry čteme PO SKUPINÁCH
* 2. Čteme DVAKRÁT s 2s pauzou
* 3. Podmínky přítomnosti:
* - Teplota/vlhkost: přítomno pokud alespoň jedna hodnota != 0
* (obě přesně 0.0 je fyzicky extrémně nepravděpodobné)
* - Tlak: přítomno pokud > 0 (0 kPa je na Zemi nemožné)
* - Hluk: přítomno pokud >= 300 (minimum 30 dB, reg ×10)
* - Vítr: přítomno pokud se alespoň jeden reg změní, nebo
* je nenulový aspoň v jednom čtení
* - PM/CO2: přítomno pokud alespoň jedno čtení != 0
* - Osvit: přítomno pokud alespoň jedno čtení != 0
* - Srážky: vždy 0 pokud neprší ověřujeme zápisem do
* kalibračního reg 0x6002 (pokud exception neosazeno)
* - Kompas: přítomno pokud se hodnota mění, nebo je nenulová
* - Solar: přítomno pokud alespoň jedno čtení != 0
*/
#include "snr.h"
static float rawToTemp(uint16_t raw) {
return (float)(int16_t)raw / 10.0f;
}
// ─── Detekce ──────────────────────────────────────────────────────────────────
void detectSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg) {
SensorPresence& p = data.present;
uint8_t addr = cfg.modbusAddr;
// Resetujeme vše na nepřítomno
p = SensorPresence();
Serial.println(F("[SNS] === Detekce senzoru (2x cteni) ==="));
// Čtení #1: celý blok 500515
uint16_t r1[16] = {0};
ModbusStatus st1 = mb.readRegisters(addr, REG_WIND_SPEED, 16, r1);
if (st1 != ModbusStatus::OK) {
Serial.printf("[SNS] Chyba 1. cteni: %s\n", mb.statusStr(st1));
data.commError = true;
data.errorMsg = mb.statusStr(st1);
return;
}
Serial.print(F("[SNS] Cteni #1: "));
for (int i = 0; i < 16; i++) { Serial.printf("%04X ", r1[i]); }
Serial.println();
// Pauza 2 s dáme senzorům čas vrátit různé hodnoty
delay(2000);
// Čtení #2
uint16_t r2[16] = {0};
ModbusStatus st2 = mb.readRegisters(addr, REG_WIND_SPEED, 16, r2);
if (st2 != ModbusStatus::OK) {
Serial.printf("[SNS] Chyba 2. cteni: %s\n", mb.statusStr(st2));
data.commError = true;
data.errorMsg = mb.statusStr(st2);
return;
}
Serial.print(F("[SNS] Cteni #2: "));
for (int i = 0; i < 16; i++) { Serial.printf("%04X ", r2[i]); }
Serial.println();
data.commError = false;
data.errorMsg = "";
// ── VÍTR (reg 500503): windSpeed, windForce, windDir8, windDir360 ───
// Vítr (ultrazvukový) je ZÁKLADNÍ senzor, přítomný na VŠECH variantách
// RS-FSXCS-N01-*. Při bezvětří vrací 0,0,0,0 což je validní stav.
// Proto: pokud čtení bloku proběhlo OK, vítr je vždy přítomný.
{
p.wind = true;
Serial.printf("[SNS] Vitr: r1=[%d,%d,%d,%d] r2=[%d,%d,%d,%d] => ANO (zakladni senzor)\n",
r1[0], r1[1], r1[2], r1[3],
r2[0], r2[1], r2[2], r2[3]);
}
// ── TEPLOTA + VLHKOST (reg 504505) ──────────────────────────────────
// 504=vlhkost×10, 505=teplota×10 (signed)
// Obě přesně 0 je fyzicky téměř nemožné (0.0°C a 0.0%RH)
{
bool ok1 = (r1[4] != 0 || r1[5] != 0);
bool ok2 = (r2[4] != 0 || r2[5] != 0);
p.tempHum = ok1 || ok2;
Serial.printf("[SNS] Temp/RH: r1=[%d,%d] r2=[%d,%d] => %s\n",
r1[4], r1[5], r2[4], r2[5], p.tempHum?"ANO":"NE");
}
// ── HLUK (reg 506) ───────────────────────────────────────────────────
// Rozsah snímače 30120 dB → registr 3001200 (×10).
// POZOR: neosazený senzor vrací konstantně 300 (= 30.0 dB).
// Přítomno pokud: hodnota > 300, NEBO se mění mezi čteními.
// Přesně 300 v obou čteních = pravděpodobně neosazeno.
{
bool inRange1 = (r1[6] >= 300 && r1[6] <= 1200);
bool inRange2 = (r2[6] >= 300 && r2[6] <= 1200);
bool changed = (r1[6] != r2[6]);
bool aboveMin = (r1[6] > 300 || r2[6] > 300);
// Přítomno pokud je v rozsahu A (hodnota nad minimem NEBO se mění)
p.noise = (inRange1 || inRange2) && (aboveMin || changed);
Serial.printf("[SNS] Hluk: r1=%d r2=%d chg=%d abv=%d => %s\n",
r1[6], r2[6], changed, aboveMin, p.noise?"ANO":"NE");
}
// ── PM2.5/PM10 nebo CO2 (reg 507508) ───────────────────────────────
// Přítomno pokud alespoň v jednom čtení != 0
{
bool anyNZ1 = (r1[7] != 0 || r1[8] != 0);
bool anyNZ2 = (r2[7] != 0 || r2[8] != 0);
bool present = anyNZ1 || anyNZ2;
if (present) {
uint8_t mode = cfg.slot507Mode;
if (mode == 0) {
// Auto: CO2 typicky 4005000 ppm
uint16_t v = (r2[7] != 0) ? r2[7] : r1[7];
mode = (v >= 400 && v <= 5000) ? 2 : 1;
}
if (mode == 2) {
p.co2 = true;
} else {
p.pm25 = true;
p.pm10 = true;
}
}
Serial.printf("[SNS] PM/CO2: r1=[%d,%d] r2=[%d,%d] => pm25=%s pm10=%s co2=%s\n",
r1[7], r1[8], r2[7], r2[8],
p.pm25?"ANO":"NE", p.pm10?"ANO":"NE", p.co2?"ANO":"NE");
}
// ── TLAK (reg 509) ──────────────────────────────────────────────────
// 0 kPa je na Zemi fyzicky nemožné. Normální rozsah 85108 kPa.
{
p.pressure = (r1[9] > 0 || r2[9] > 0);
Serial.printf("[SNS] Tlak: r1=%d r2=%d => %s\n",
r1[9], r2[9], p.pressure?"ANO":"NE");
}
// ── OSVIT (reg 510512): luxH, luxL, lux/100 ───────────────────────
// 0 lux = noc/tma, validní. Ale pokud ALL 0 v obou čteních = neosazeno.
{
bool allZ1 = (r1[10]==0 && r1[11]==0 && r1[12]==0);
bool allZ2 = (r2[10]==0 && r2[11]==0 && r2[12]==0);
p.light = !(allZ1 && allZ2);
Serial.printf("[SNS] Osvit: r1=[%d,%d,%d] r2=[%d,%d,%d] => %s\n",
r1[10],r1[11],r1[12], r2[10],r2[11],r2[12],
p.light?"ANO":"NE");
}
// ── SRÁŽKY (reg 513) ────────────────────────────────────────────────
// 0 mm je validní (neprší). Hodnota je kumulativní.
// Ověříme zápisem do kalibračního reg: pokud snímač nemá
// srážkoměr, zápis do REG_RAIN_ZERO (0x6002) vrátí exception.
{
if (r1[13] > 0 || r2[13] > 0) {
p.rainfall = true;
} else {
// Hodnota je 0 pokusíme se ověřit přes kalibrační registr
// Čteme reg 0x6003 (rain sensitivity) pokud OK, srážkoměr je
uint16_t dummy = 0;
ModbusStatus st = mb.readRegisters(addr, REG_RAIN_SENS, 1, &dummy);
p.rainfall = (st == ModbusStatus::OK && dummy != 0 && dummy != 0xFFFF);
Serial.printf("[SNS] Srazky: kalib.reg test: %s (val=0x%04X)\n",
mb.statusStr(st), dummy);
}
Serial.printf("[SNS] Srazky: r1=%d r2=%d => %s\n",
r1[13], r2[13], p.rainfall?"ANO":"NE");
}
// ── KOMPAS (reg 514) ────────────────────────────────────────────────
// 0 = sever, validní. Ale neosazený kompas vrací stále 0.
// Přítomno pokud nenulový, nebo se mění.
{
bool nonZero = (r1[14] != 0 || r2[14] != 0);
bool changed = (r1[14] != r2[14]);
p.compass = nonZero || changed;
Serial.printf("[SNS] Kompas: r1=%d r2=%d => %s\n",
r1[14], r2[14], p.compass?"ANO":"NE");
}
// ── SOLÁRNÍ ZÁŘENÍ (reg 515) ────────────────────────────────────────
// 0 W/m² validní (noc). Přítomno pokud alespoň jednou != 0.
{
p.solar = (r1[15] != 0 || r2[15] != 0);
Serial.printf("[SNS] Solar: r1=%d r2=%d => %s\n",
r1[15], r2[15], p.solar?"ANO":"NE");
}
Serial.println(F("[SNS] === Detekce dokoncena ==="));
}
// ─── Čtení všech snímačů (bulk) ─────────────────────────────────────────────
void readAllSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg) {
uint16_t buf[16];
ModbusStatus st = mb.readRegisters(cfg.modbusAddr, REG_WIND_SPEED, 16, buf);
if (st != ModbusStatus::OK) {
data.commError = true;
data.errorMsg = mb.statusStr(st);
Serial.printf("[SNS] Chyba cteni: %s\n", mb.statusStr(st));
return;
}
data.commError = false;
data.errorMsg = "";
data.lastUpdateMs = millis();
const SensorPresence& p = data.present;
if (p.wind) {
data.windSpeed = buf[0] / 10.0f;
data.windForce = buf[1];
data.windDir8 = (uint8_t)(buf[2] & 0x07);
data.windDir360 = buf[3];
}
if (p.tempHum) {
data.humidity = buf[4] / 10.0f;
data.temperature = rawToTemp(buf[5]);
}
if (p.noise) data.noise = buf[6] / 10.0f;
if (p.pressure) data.pressure = buf[9] / 10.0f;
// PM / CO2
if (p.co2) data.co2 = buf[7];
if (p.pm25) data.pm25 = buf[7];
if (p.pm10) data.pm10 = buf[8];
if (p.light) {
data.luxFull = ((uint32_t)buf[10] << 16) | buf[11];
data.lux100 = buf[12];
}
if (p.rainfall) data.rainfall = buf[13] / 10.0f;
if (p.compass) data.compass = buf[14] / 100.0f;
if (p.solar) data.solar = buf[15];
}
// ─── Výpis stavu ─────────────────────────────────────────────────────────────
void printSensorStatus(const SensorData& data) {
const SensorPresence& p = data.present;
Serial.println(F("[SNS] +--------------------------+"));
Serial.println(F("[SNS] | Detekce senzoru |"));
Serial.println(F("[SNS] +--------------------------+"));
Serial.printf("[SNS] | Vitr: %-3s |\n", p.wind ?"ANO":"NE");
Serial.printf("[SNS] | Teplota/RH: %-3s |\n", p.tempHum ?"ANO":"NE");
Serial.printf("[SNS] | Tlak: %-3s |\n", p.pressure ?"ANO":"NE");
Serial.printf("[SNS] | Hluk: %-3s |\n", p.noise ?"ANO":"NE");
Serial.printf("[SNS] | PM2.5: %-3s |\n", p.pm25 ?"ANO":"NE");
Serial.printf("[SNS] | PM10: %-3s |\n", p.pm10 ?"ANO":"NE");
Serial.printf("[SNS] | CO2: %-3s |\n", p.co2 ?"ANO":"NE");
Serial.printf("[SNS] | Osvit: %-3s |\n", p.light ?"ANO":"NE");
Serial.printf("[SNS] | Srazky: %-3s |\n", p.rainfall ?"ANO":"NE");
Serial.printf("[SNS] | Kompas: %-3s |\n", p.compass ?"ANO":"NE");
Serial.printf("[SNS] | Solar: %-3s |\n", p.solar ?"ANO":"NE");
Serial.println(F("[SNS] +--------------------------+"));
}

14
SW/ESPlan_WeatherStation/snr.h Executable file
View File

@ -0,0 +1,14 @@
#ifndef WS_SNR_H
#define WS_SNR_H
/*
* snr.h Detekce variant snímače a čtení dat
*/
#include "cfg.h"
#include "mb_client.h"
void detectSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg);
void readAllSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg);
void printSensorStatus(const SensorData& data);
#endif // WS_SNR_H

View File

@ -0,0 +1,710 @@
/*
* ws_server.cpp HTTP webserver implementace
*/
#include "ws_server.h"
#include <ETH.h>
// ─── Globální pointery ────────────────────────────────────────────────────────
static WebServer* gServer = nullptr;
static SensorData* gData = nullptr;
static SensorConfig* gCfg = nullptr;
static Preferences* gPrefs = nullptr;
static ModbusClient* gMb = nullptr;
// ─── Pomocné ─────────────────────────────────────────────────────────────────
static bool isLangCz() {
if (gServer->hasArg("lang")) {
String l = gServer->arg("lang");
return !(l == "en");
}
if (gServer->hasHeader("Cookie")) {
String c = gServer->header("Cookie");
if (c.indexOf("lang=en") >= 0) return false;
}
return true;
}
static void sendLangCookie(bool cz) {
gServer->sendHeader("Set-Cookie",
cz ? "lang=cs; Path=/; Max-Age=31536000"
: "lang=en; Path=/; Max-Age=31536000");
}
static String fmtFloat(float v, int dec = 1) {
char buf[20];
snprintf(buf, sizeof(buf), "%.*f", dec, v);
return String(buf);
}
// ─── CSS ─────────────────────────────────────────────────────────────────────
static const char CSS[] PROGMEM = R"CSS(
:root{
--bg:#0d1117;--bg2:#161b22;--bg3:#21262d;
--border:#30363d;--border2:#484f58;
--text:#e6edf3;--text2:#8b949e;--text3:#6e7681;
--accent:#58a6ff;--accent2:#1f6feb;
--green:#3fb950;--red:#f85149;--yellow:#d29922;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;
min-height:100vh;font-size:14px;line-height:1.5}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
.topbar{background:var(--bg2);border-bottom:1px solid var(--border);
padding:0 24px;display:flex;align-items:center;height:52px;
position:sticky;top:0;z-index:100}
.logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;
color:var(--text);margin-right:32px;white-space:nowrap}
.logo-icon{width:28px;height:28px;background:#1f6feb;border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0}
.nav-links{display:flex;gap:2px;flex:1}
.nav-link{padding:6px 14px;border-radius:6px;font-size:13px;
color:var(--text2);transition:background .15s,color .15s;white-space:nowrap}
.nav-link:hover{background:var(--bg3);color:var(--text);text-decoration:none}
.nav-link.active{background:var(--accent2);color:#fff}
.lang-btn{margin-left:auto;padding:5px 12px;border-radius:20px;
background:var(--bg3);border:1px solid var(--border2);
color:var(--text2);font-size:12px;white-space:nowrap}
.lang-btn:hover{background:var(--border);color:var(--text);text-decoration:none}
.main{max-width:1100px;margin:0 auto;padding:28px 20px}
h1{font-size:20px;font-weight:600;margin-bottom:20px}
h2{font-size:12px;font-weight:600;color:var(--text3);text-transform:uppercase;
letter-spacing:.08em;margin:28px 0 12px;padding-bottom:8px;
border-bottom:1px solid var(--border)}
h2:first-of-type{margin-top:0}
.card{background:var(--bg2);border:1px solid var(--border);
border-radius:10px;padding:16px 20px;margin-bottom:16px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{padding:8px 12px;text-align:left;font-weight:600;font-size:11px;
color:var(--text3);text-transform:uppercase;letter-spacing:.06em;
border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.02)}
.val{font-family:'Courier New',monospace;font-size:13px;color:var(--text)}
.unit{color:var(--text3);font-size:12px}
.badge-yes{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-size:12px}
.badge-no{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:12px}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot-g{background:var(--green);box-shadow:0 0 6px var(--green);
animation:pulse 2s ease-in-out infinite}
.dot-x{background:var(--text3)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
.smeta{margin-left:auto;font-size:12px;color:var(--text3);font-family:'Courier New',monospace}
.err-banner{background:rgba(248,81,73,.1);border:1px solid var(--red);
border-radius:8px;padding:10px 16px;color:var(--red);font-size:13px;
margin-bottom:16px;display:flex;align-items:center;gap:8px}
.ip-bar{display:flex;align-items:center;gap:20px;padding:10px 16px;
background:var(--bg3);border:1px solid var(--border);border-radius:8px;
font-family:'Courier New',monospace;font-size:12px;color:var(--text2);
margin-bottom:20px;flex-wrap:wrap}
.ip-lbl{color:var(--text3)}
.btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;
border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent;
font-family:inherit;transition:background .15s;text-decoration:none}
.btn:active{opacity:.8}
.btn-p{background:var(--accent2);color:#fff;border-color:var(--accent2)}
.btn-p:hover{background:#388bfd;text-decoration:none;color:#fff}
.btn-d{background:var(--bg3);color:var(--text);border-color:var(--border2)}
.btn-d:hover{background:var(--border);text-decoration:none}
.btn-w{background:transparent;color:var(--yellow);border-color:var(--yellow)}
.btn-w:hover{background:rgba(210,153,34,.1);text-decoration:none}
.btn-sm{padding:4px 10px;font-size:12px}
.fg{margin-bottom:18px}
label{display:block;font-size:13px;font-weight:500;color:var(--text2);margin-bottom:5px}
.ht{font-size:11px;color:var(--text3);margin-top:4px}
input[type=number],input[type=text],select{
background:var(--bg3);border:1px solid var(--border2);border-radius:6px;
padding:7px 10px;color:var(--text);font-size:13px;width:100%;
max-width:280px;outline:none;transition:border-color .15s;font-family:inherit}
input:focus,select:focus{border-color:var(--accent)}
select option{background:var(--bg3)}
.frow{display:flex;gap:16px;flex-wrap:wrap}
.frow .fg{flex:1;min-width:200px}
.act-row{display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start}
.act-item{flex:1;min-width:200px}
.live-dot{width:8px;height:8px;border-radius:50%;background:var(--green);
box-shadow:0 0 6px var(--green);animation:pulse 2s ease-in-out infinite;
flex-shrink:0;margin-left:auto}
.val.flash{animation:valflash .4s ease-out}
@keyframes valflash{0%{color:var(--accent)}100%{color:var(--text)}}
.raw-panel{background:var(--bg2);border:1px solid var(--border);border-radius:10px;
padding:14px 20px;margin-bottom:16px;font-family:'Courier New',monospace;
font-size:12px;line-height:1.8}
.raw-tx{color:var(--yellow)}
.raw-rx{color:var(--green)}
.raw-err{color:var(--red)}
.raw-label{color:var(--text3);display:inline-block;width:28px}
.raw-status{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:600;text-transform:uppercase;margin-left:8px}
.raw-status.ok{background:rgba(63,185,80,.15);color:var(--green)}
.raw-status.err{background:rgba(248,81,73,.15);color:var(--red)}
@media(max-width:640px){
.topbar{padding:0 12px}
.logo{margin-right:12px}
.main{padding:16px 12px}
th,td{padding:7px 8px}
input,select{max-width:100%}
.smeta{margin-left:0}
}
)CSS";
// ─── HTML head + nav ─────────────────────────────────────────────────────────
static String htmlHead(const I18nStrings& s, bool cz, const char* page) {
String langUrl = cz ? "?lang=en" : "?lang=cs";
String out;
out.reserve(3000);
out += F("<!DOCTYPE html><html lang='");
out += cz ? "cs" : "en";
out += F("'><head><meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>");
out += s.title;
out += F("</title><style>");
out += FPSTR(CSS);
out += F("</style></head><body>"
"<nav class='topbar'>"
"<div class='logo'>"
"<div class='logo-icon'>"
"<svg width='18' height='18' viewBox='0 0 18 18' fill='none'>"
"<circle cx='9' cy='9' r='3' stroke='#58a6ff' stroke-width='1.5'/>"
"<line x1='9' y1='2' x2='9' y2='5' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"<line x1='9' y1='13' x2='9' y2='16' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"<line x1='2' y1='9' x2='5' y2='9' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"<line x1='13' y1='9' x2='16' y2='9' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"</svg></div>");
out += s.title;
out += F("</div><div class='nav-links'>");
out += F("<a href='/' class='nav-link");
if (strcmp(page,"status")==0) out += F(" active");
out += F("'>");
out += s.navStatus;
out += F("</a><a href='/config' class='nav-link");
if (strcmp(page,"config")==0) out += F(" active");
out += F("'>");
out += s.navConfig;
out += F("</a></div><a href='");
out += langUrl;
out += F("' class='lang-btn'>");
out += s.langSwitch;
out += F("</a></nav><div class='main'>");
return out;
}
static String htmlFoot() {
return F("</div></body></html>");
}
// ─── Stránka: Stav ────────────────────────────────────────────────────────────
static void handleRoot() {
bool cz = isLangCz();
sendLangCookie(cz);
const I18nStrings& s = getStrings(cz);
String html = htmlHead(s, cz, "status");
// IP bar
html += F("<div class='ip-bar'>"
"<span><span class='ip-lbl'>IP: </span><b>");
html += ETH.localIP().toString();
html += F("</b></span><span><span class='ip-lbl'>MAC: </span>");
html += ETH.macAddress();
html += F("</span><span><span class='ip-lbl'>ETH: </span>");
html += ETH.linkSpeed();
html += F(" Mbit/s</span></div>");
if (gData->commError) {
html += F("<div class='err-banner'>&#9888; ");
html += s.commError;
html += F(": ");
html += gData->errorMsg;
html += F("</div>");
}
// Toolbar
html += F("<div class='toolbar'>"
"<form method='POST' action='/detect' style='display:inline'>"
"<button type='submit' class='btn btn-d btn-sm'>");
html += s.btnDetect;
html += F("</button></form>"
"<span class='live-dot'></span>"
"<span id='updInfo' class='smeta'></span>");
html += F("</div>");
// Tabulka detekce
html += F("<h2>");
html += s.sectionSensors;
html += F("</h2><div class='card'><table>"
"<thead><tr><th>");
html += s.colSensor;
html += F("</th><th>");
html += s.colPresent;
html += F("</th></tr></thead><tbody>");
auto row = [&](const char* name, bool present) {
html += F("<tr><td>");
html += name;
html += F("</td><td>");
if (present) {
html += F("<span class='badge-yes'><span class='dot dot-g'></span>");
html += s.yes;
} else {
html += F("<span class='badge-no'><span class='dot dot-x'></span>");
html += s.no;
}
html += F("</span></td></tr>");
};
const SensorPresence& p = gData->present;
row(s.snsWind, p.wind);
row(s.snsTempHum, p.tempHum);
row(s.snsPressure, p.pressure);
row(s.snsNoise, p.noise);
row(s.snsPM25, p.pm25);
row(s.snsPM10, p.pm10);
row(s.snsCO2, p.co2);
row(s.snsLight, p.light);
row(s.snsRainfall, p.rainfall);
row(s.snsCompass, p.compass);
row(s.snsSolar, p.solar);
html += F("</tbody></table></div>");
// Tabulka hodnot
html += F("<h2>");
html += s.sectionValues;
html += F("</h2><div class='card'><table><thead><tr><th>");
html += s.colSensor;
html += F("</th><th>");
html += s.colValue;
html += F("</th><th>");
html += s.colUnit;
html += F("</th></tr></thead><tbody>");
auto valRow = [&](const char* id, const char* name, bool present,
const String& val, const char* unit) {
html += F("<tr><td>");
html += name;
html += F("</td><td class='val' id='v_");
html += id;
html += F("'>");
html += present ? val : s.noData;
html += F("</td><td class='unit' id='u_");
html += id;
html += F("'>");
html += present ? unit : "";
html += F("</td></tr>");
};
const SensorData& d = *gData;
if (p.wind) {
valRow("ws", s.snsWindSpeed, true, fmtFloat(d.windSpeed), "m/s");
valRow("wf", s.snsWindForce, true, String(d.windForce), "Bft");
String dirTxt = String(d.windDir8) + " (" +
s.windDirNames[d.windDir8 < 8 ? d.windDir8 : 0] + ")";
valRow("wd", s.snsWindDir, true, dirTxt, "");
valRow("w3", s.snsWindDir360, true, String(d.windDir360), "&deg;");
}
if (p.tempHum) {
valRow("te", s.snsTemp, true, fmtFloat(d.temperature), "&deg;C");
valRow("hu", s.snsHum, true, fmtFloat(d.humidity), "%RH");
}
if (p.pressure) valRow("pr", s.snsPressure, true, fmtFloat(d.pressure,2), "kPa");
if (p.noise) valRow("no", s.snsNoise, true, fmtFloat(d.noise), "dB");
if (p.pm25) valRow("p2", s.snsPM25, true, String(d.pm25), "&micro;g/m&sup3;");
if (p.pm10) valRow("p1", s.snsPM10, true, String(d.pm10), "&micro;g/m&sup3;");
if (p.co2) valRow("co", s.snsCO2, true, String(d.co2), "ppm");
if (p.light) valRow("lx", s.snsLight, true, String(d.luxFull), "lux");
if (p.rainfall) valRow("rn", s.snsRainfall, true, fmtFloat(d.rainfall), "mm");
if (p.compass) valRow("cp", s.snsCompass, true, fmtFloat(d.compass,1), "&deg;");
if (p.solar) valRow("sl", s.snsSolar, true, String(d.solar), "W/m&sup2;");
html += F("</tbody></table></div>");
// ── Raw RS485 panel ────────────────────────────────────────────────────
html += F("<h2>");
html += s.sectionRaw;
html += F("</h2><div class='raw-panel'>"
"<div><span class='raw-label'>TX:</span> <span class='raw-tx' id='rawTx'>...</span>"
" <span class='raw-status' id='rawSt'></span></div>"
"<div><span class='raw-label'>RX:</span> <span class='raw-rx' id='rawRx'>...</span></div>"
"</div>");
// Inline JavaScript AJAX auto-refresh
html += F("<script>"
"var dn=['N','NE','E','SE','S','SW','W','NW'];"
"function u(id,v){"
"var e=document.getElementById(id);"
"if(!e)return;"
"var s=String(v);"
"if(e.textContent!==s){"
"e.textContent=s;"
"e.classList.remove('flash');"
"void e.offsetWidth;"
"e.classList.add('flash');"
"}"
"}"
"function updRaw(){"
"fetch('/api/raw').then(function(r){return r.json()}).then(function(d){"
"var tx=document.getElementById('rawTx');"
"var rx=document.getElementById('rawRx');"
"var st=document.getElementById('rawSt');"
"if(tx)tx.textContent=d.tx||'—';"
"if(rx){rx.textContent=d.rx||'—';"
"rx.className=(d.s==='OK')?'raw-rx':'raw-err';}"
"if(st){st.textContent=d.s;"
"st.className='raw-status '+(d.s==='OK'?'ok':'err');}"
"}).catch(function(){});"
"}"
"function upd(){"
"fetch('/api/data').then(function(r){return r.json()}).then(function(d){"
"if(d.err){"
"document.getElementById('updInfo').textContent='\\u26A0 error';"
"return;"
"}"
"if(d.windSpeed!==undefined){"
"u('v_ws',d.windSpeed.toFixed(1));"
"u('v_wf',d.windForce);"
"u('v_wd',d.windDir8+' ('+(dn[d.windDir8]||'?')+')');"
"u('v_w3',d.windDir360);"
"}"
"if(d.temp!==undefined) u('v_te',d.temp.toFixed(1));"
"if(d.hum!==undefined) u('v_hu',d.hum.toFixed(1));"
"if(d.pres!==undefined) u('v_pr',d.pres.toFixed(2));"
"if(d.noise!==undefined) u('v_no',d.noise.toFixed(1));"
"if(d.pm25!==undefined) u('v_p2',d.pm25);"
"if(d.pm10!==undefined) u('v_p1',d.pm10);"
"if(d.co2!==undefined) u('v_co',d.co2);"
"if(d.lux!==undefined) u('v_lx',d.lux);"
"if(d.rain!==undefined) u('v_rn',d.rain.toFixed(1));"
"if(d.compass!==undefined) u('v_cp',d.compass.toFixed(1));"
"if(d.solar!==undefined) u('v_sl',d.solar);"
"document.getElementById('updInfo').textContent="
"new Date().toLocaleTimeString();"
"}).catch(function(){"
"document.getElementById('updInfo').textContent='\\u26A0 offline';"
"});"
"}"
"function tick(){upd();updRaw();}"
"tick();setInterval(tick,5000);"
"</script>");
html += htmlFoot();
gServer->send(200, "text/html; charset=UTF-8", html);
}
// ─── POST /detect ─────────────────────────────────────────────────────────────
static void handleDetect() {
detectSensors(*gMb, *gData, *gCfg);
bool cz = isLangCz();
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/?lang=cs" : "/?lang=en");
gServer->send(303);
}
// ─── GET /config ──────────────────────────────────────────────────────────────
static void handleConfig() {
bool cz = isLangCz();
sendLangCookie(cz);
const I18nStrings& s = getStrings(cz);
String html = htmlHead(s, cz, "config");
html += F("<h1>");
html += s.pageConfigTitle;
html += F("</h1><form method='POST' action='/config'>");
html += F("<input type='hidden' name='lang' value='");
html += cz ? "cs" : "en";
html += F("'>");
// Komunikace
html += F("<h2>");
html += s.sectionComm;
html += F("</h2><div class='card'><div class='frow'>");
html += F("<div class='fg'><label>");
html += s.cfgAddr;
html += F("</label><input type='number' name='addr' min='1' max='247' value='");
html += gCfg->modbusAddr;
html += F("'><div class='ht'>");
html += s.cfgAddrHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgBaud;
html += F("</label><select name='baud'>");
const uint32_t bauds[] = {1200,2400,4800,9600,19200,38400,57600,115200};
for (auto b : bauds) {
html += F("<option value='");
html += b;
html += F("'");
if (b == gCfg->baudRate) html += F(" selected");
html += F(">");
html += b;
html += F(" bps</option>");
}
html += F("</select><div class='ht'>");
html += s.cfgBaudHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgPoll;
html += F("</label><input type='number' name='poll' min='1' max='60' value='");
html += gCfg->pollInterval;
html += F("'><div class='ht'>");
html += s.cfgPollHelp;
html += F("</div></div></div></div>");
// Kalibrace
html += F("<h2>");
html += s.sectionCalib;
html += F("</h2><div class='card'><div class='frow'>");
html += F("<div class='fg'><label>");
html += s.cfgWindDirOffset;
html += F("</label><select name='wdOffset'>"
"<option value='0'");
if (gCfg->windDirOffset == 0) html += F(" selected");
html += F(">");
html += s.cfgWindDirOff0;
html += F("</option><option value='1'");
if (gCfg->windDirOffset == 1) html += F(" selected");
html += F(">");
html += s.cfgWindDirOff1;
html += F("</option></select><div class='ht'>");
html += s.cfgWindDirOffsetHelp;
html += F("</div></div>");
char rainHex[8];
snprintf(rainHex, sizeof(rainHex), "0x%02X", gCfg->rainSensitivity);
html += F("<div class='fg'><label>");
html += s.cfgRainSens;
html += F("</label><input type='text' name='rainSens' value='");
html += rainHex;
html += F("' maxlength='4'><div class='ht'>");
html += s.cfgRainSensHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgSlot507;
html += F("</label><select name='slot507'>"
"<option value='0'");
if (gCfg->slot507Mode==0) html += F(" selected");
html += F(">");
html += s.cfgSlotAuto;
html += F("</option><option value='1'");
if (gCfg->slot507Mode==1) html += F(" selected");
html += F(">");
html += s.cfgSlotPM;
html += F("</option><option value='2'");
if (gCfg->slot507Mode==2) html += F(" selected");
html += F(">");
html += s.cfgSlotCO2;
html += F("</option></select><div class='ht'>");
html += s.cfgSlot507Help;
html += F("</div></div></div></div>");
html += F("<div style='margin-top:8px'>"
"<button type='submit' class='btn btn-p'>");
html += s.btnSave;
html += F("</button></div></form>");
// Akce
html += F("<h2>");
html += s.sectionActions;
html += F("</h2><div class='card'><div class='act-row'>");
html += F("<div class='act-item'>"
"<form method='POST' action='/action'>"
"<input type='hidden' name='act' value='wind_zero'>"
"<input type='hidden' name='lang' value='");
html += cz ? "cs" : "en";
html += F("'><button type='submit' class='btn btn-w'>");
html += s.btnWindZero;
html += F("</button></form><div class='ht' style='margin-top:6px'>");
html += s.btnWindZeroHelp;
html += F("</div></div>");
html += F("<div class='act-item'>"
"<form method='POST' action='/action'>"
"<input type='hidden' name='act' value='rain_zero'>"
"<input type='hidden' name='lang' value='");
html += cz ? "cs" : "en";
html += F("'><button type='submit' class='btn btn-w'>");
html += s.btnRainZero;
html += F("</button></form><div class='ht' style='margin-top:6px'>");
html += s.btnRainZeroHelp;
html += F("</div></div></div></div>");
html += htmlFoot();
gServer->send(200, "text/html; charset=UTF-8", html);
}
// ─── POST /config ─────────────────────────────────────────────────────────────
static void handleConfigPost() {
bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true;
if (gServer->hasArg("addr")) {
gCfg->modbusAddr = (uint8_t)constrain(gServer->arg("addr").toInt(), 1, 247);
}
if (gServer->hasArg("baud")) {
uint32_t b = (uint32_t)gServer->arg("baud").toInt();
if (b != gCfg->baudRate) { gCfg->baudRate = b; gMb->setBaudRate(b); }
}
if (gServer->hasArg("poll")) {
gCfg->pollInterval = (uint8_t)constrain(gServer->arg("poll").toInt(), 1, 60);
}
if (gServer->hasArg("wdOffset")) {
uint8_t v = (uint8_t)(gServer->arg("wdOffset").toInt() & 0x01);
if (v != gCfg->windDirOffset) {
gCfg->windDirOffset = v;
gMb->writeRegister(gCfg->modbusAddr, REG_WIND_DIR_OFFSET, v);
}
}
if (gServer->hasArg("rainSens")) {
uint8_t v = (uint8_t)strtol(gServer->arg("rainSens").c_str(), nullptr, 16);
if (v != gCfg->rainSensitivity) {
gCfg->rainSensitivity = v;
gMb->writeRegister(gCfg->modbusAddr, REG_RAIN_SENS, v);
}
}
if (gServer->hasArg("slot507")) {
gCfg->slot507Mode = (uint8_t)constrain(gServer->arg("slot507").toInt(), 0, 2);
}
gPrefs->begin("weather", false);
saveConfig(*gPrefs, *gCfg);
gPrefs->end();
Serial.println(F("[CFG] Konfigurace ulozena."));
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en");
gServer->send(303);
}
// ─── POST /action ─────────────────────────────────────────────────────────────
static void handleAction() {
bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true;
String act = gServer->arg("act");
if (act == "wind_zero") {
ModbusStatus st = gMb->writeRegister(gCfg->modbusAddr, REG_WIND_ZERO, 0xAA);
Serial.printf("[ACT] Wind zero: %s\n", gMb->statusStr(st));
} else if (act == "rain_zero") {
ModbusStatus st = gMb->writeRegister(gCfg->modbusAddr, REG_RAIN_ZERO, 0x5A);
Serial.printf("[ACT] Rain zero: %s\n", gMb->statusStr(st));
}
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en");
gServer->send(303);
}
// ─── GET /api/data ────────────────────────────────────────────────────────────
static void handleApiData() {
const SensorData& d = *gData;
String json = "{";
json += "\"ts\":" + String(d.lastUpdateMs) + ",";
json += "\"err\":" + String(d.commError ? "true" : "false");
if (d.present.wind) {
json += ",\"windSpeed\":" + fmtFloat(d.windSpeed);
json += ",\"windForce\":" + String(d.windForce);
json += ",\"windDir8\":" + String(d.windDir8);
json += ",\"windDir360\":" + String(d.windDir360);
}
if (d.present.tempHum) {
json += ",\"temp\":" + fmtFloat(d.temperature);
json += ",\"hum\":" + fmtFloat(d.humidity);
}
if (d.present.pressure) json += ",\"pres\":" + fmtFloat(d.pressure,2);
if (d.present.noise) json += ",\"noise\":" + fmtFloat(d.noise);
if (d.present.pm25) json += ",\"pm25\":" + String(d.pm25);
if (d.present.pm10) json += ",\"pm10\":" + String(d.pm10);
if (d.present.co2) json += ",\"co2\":" + String(d.co2);
if (d.present.light) json += ",\"lux\":" + String(d.luxFull);
if (d.present.rainfall) json += ",\"rain\":" + fmtFloat(d.rainfall);
if (d.present.compass) json += ",\"compass\":" + fmtFloat(d.compass);
if (d.present.solar) json += ",\"solar\":" + String(d.solar);
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── GET /api/status ──────────────────────────────────────────────────────────
static void handleApiStatus() {
const SensorPresence& p = gData->present;
String json = "{";
json += "\"wind\":" + String(p.wind ?"true":"false");
json += ",\"tempHum\":" + String(p.tempHum ?"true":"false");
json += ",\"pressure\":" + String(p.pressure?"true":"false");
json += ",\"noise\":" + String(p.noise ?"true":"false");
json += ",\"pm25\":" + String(p.pm25 ?"true":"false");
json += ",\"pm10\":" + String(p.pm10 ?"true":"false");
json += ",\"co2\":" + String(p.co2 ?"true":"false");
json += ",\"light\":" + String(p.light ?"true":"false");
json += ",\"rainfall\":" + String(p.rainfall?"true":"false");
json += ",\"compass\":" + String(p.compass ?"true":"false");
json += ",\"solar\":" + String(p.solar ?"true":"false");
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── GET /api/raw Poslední TX/RX frame ─────────────────────────────────────
static String hexBuf(const uint8_t* buf, uint8_t len) {
String s;
s.reserve(len * 3);
for (uint8_t i = 0; i < len; i++) {
char h[4];
snprintf(h, sizeof(h), "%02X ", buf[i]);
s += h;
}
if (s.length() > 0) s.remove(s.length() - 1);
return s;
}
static void handleApiRaw() {
String json = "{";
json += "\"tx\":\"" + hexBuf(gMb->lastTx, gMb->lastTxLen) + "\"";
json += ",\"rx\":\"" + hexBuf(gMb->lastRx, gMb->lastRxLen) + "\"";
json += ",\"s\":\"" + String(gMb->statusStr(gMb->lastResult)) + "\"";
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── Inicializace ─────────────────────────────────────────────────────────────
void webServerBegin(SensorData& data, SensorConfig& cfg,
Preferences& prefs, ModbusClient& mb) {
gData = &data;
gCfg = &cfg;
gPrefs = &prefs;
gMb = &mb;
gServer = new WebServer(80);
static const char* hdrs[] = {"Cookie"};
gServer->collectHeaders(hdrs, 1);
gServer->on("/", HTTP_GET, handleRoot);
gServer->on("/detect", HTTP_POST, handleDetect);
gServer->on("/config", HTTP_GET, handleConfig);
gServer->on("/config", HTTP_POST, handleConfigPost);
gServer->on("/action", HTTP_POST, handleAction);
gServer->on("/api/data", HTTP_GET, handleApiData);
gServer->on("/api/status", HTTP_GET, handleApiStatus);
gServer->on("/api/raw", HTTP_GET, handleApiRaw);
gServer->onNotFound([]() {
gServer->send(404, "text/plain", "Not found");
});
gServer->begin();
Serial.println(F("[WEB] HTTP server spusten na portu 80"));
}
void webServerHandle() {
if (gServer) gServer->handleClient();
}

View File

@ -0,0 +1,28 @@
#ifndef WS_SERVER_H
#define WS_SERVER_H
/*
* ws_server.h HTTP webserver
*
* Stránky:
* GET / Stav snímačů + živá data
* GET /config Konfigurace snímače
* POST /config Uložení konfigurace
* POST /detect Re-detekce snímačů
* POST /action Akce: wind_zero, rain_zero
* GET /api/data JSON s aktuálními daty
* GET /api/status JSON se stavem detekce
*/
#include <WebServer.h>
#include <Preferences.h>
#include "cfg.h"
#include "mb_client.h"
#include "snr.h"
#include "i18n_str.h"
void webServerBegin(SensorData& data, SensorConfig& cfg,
Preferences& prefs, ModbusClient& mb);
void webServerHandle();
#endif // WS_SERVER_H

View File

@ -0,0 +1,132 @@
/*
* ESPlan_ZTS3000_WindSensor.ino
* ZTS-3000-FSJT-N01 - Polykarbonatovy 3-poharovy anemometr
*
* Hardware : LaskaKit ESPlan (ESP32 + LAN8720)
* Snimac : ZTS-3000-FSJT-N01 (Modbus RTU / RS485)
* Komunikace: pouze ETH (LAN8720)
*
* Soubory projektu:
* cfg.h - HW konstanty, registry, datove struktury
* mb_client.h/cpp - Modbus RTU klient (FC03, FC06)
* snr.h/cpp - Detekce a cteni snimace
* i18n_str.h - Preklady CZ / EN
* ws_server.h/cpp - HTTP webserver (stav + konfigurace)
*
* Modbus-RTU parametry snimace (datasheet):
* adresa: 0x01 (vychozi), bps: 4800, 8N1
* FC03 cteni 2 registru od 0x0000:
* 0x0000 = rychlost vetru x10 (m/s)
* 0x0001 = sila vetru (Beaufort 0-17)
* FC06 zapis adresy do reg 0x07D0
*/
#include <Arduino.h>
#include <WiFi.h> // nutne pro WiFiEvent_t a WiFi.onEvent
#include <ETH.h>
#include <Preferences.h>
#include "cfg.h"
#include "mb_client.h"
#include "snr.h"
#include "ws_server.h"
// ─── Globalni objekty ─────────────────────────────────────────────────────────
Preferences prefs;
SensorData sensorData;
SensorConfig sensorConfig;
ModbusClient modbusClient;
static bool ethGotIP = false;
static uint32_t lastPollMs = 0;
// ─── ETH udalosti ─────────────────────────────────────────────────────────────
void onEthEvent(WiFiEvent_t event) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
Serial.println(F("[ETH] Inicializace..."));
ETH.setHostname("espplan-zts3000");
break;
case ARDUINO_EVENT_ETH_CONNECTED:
Serial.println(F("[ETH] Pripojen (fyzicka vrstva)"));
break;
case ARDUINO_EVENT_ETH_GOT_IP:
Serial.print(F("[ETH] IP: "));
Serial.println(ETH.localIP());
ethGotIP = true;
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
Serial.println(F("[ETH] Odpojeno"));
ethGotIP = false;
break;
case ARDUINO_EVENT_ETH_STOP:
Serial.println(F("[ETH] Zastaveno"));
break;
default:
break;
}
}
// ─── Setup ────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(500);
Serial.println(F("\n=== ESPlan ZTS-3000 Anemometr ==="));
Serial.println(F(" ZTS-3000-FSJT-N01 Modbus RTU "));
Serial.println(F("================================"));
// Nacteni konfigurace z NVS
prefs.begin("zts3000", true);
loadConfig(prefs, sensorConfig);
prefs.end();
Serial.printf("[CFG] Modbus addr=%d baud=%d poll=%ds\n",
sensorConfig.modbusAddr,
sensorConfig.baudRate,
sensorConfig.pollInterval);
// RS485 / Modbus
modbusClient.begin(sensorConfig.baudRate, sensorConfig.modbusAddr);
// ETH
WiFi.onEvent(onEthEvent);
ETH.begin(ETH_PHY_TYPE,
ETH_PHY_ADDR,
ETH_PHY_MDC,
ETH_PHY_MDIO,
ETH_PHY_POWER,
ETH_CLK_MODE);
// Cekame na IP (max 10 s)
uint32_t t0 = millis();
while (!ethGotIP && (millis() - t0 < 10000)) {
delay(100);
}
if (!ethGotIP) {
Serial.println(F("[ETH] VAROVANI: IP neziskano - web nebude dostupny!"));
}
// Webserver
webServerBegin(sensorData, sensorConfig, prefs, modbusClient);
// Prvni detekce
Serial.println(F("[SNS] Detekuji snimac..."));
detectSensors(modbusClient, sensorData, sensorConfig);
printSensorStatus(sensorData);
Serial.println(F("[SYS] Inicializace dokoncena."));
}
// ─── Loop ─────────────────────────────────────────────────────────────────────
void loop() {
webServerHandle();
uint32_t now = millis();
if (now - lastPollMs >= (uint32_t)sensorConfig.pollInterval * 1000UL) {
lastPollMs = now;
readAllSensors(modbusClient, sensorData, sensorConfig);
}
delay(5);
}

View File

@ -0,0 +1,120 @@
#ifndef WS_CFG_H
#define WS_CFG_H
/*
* cfg.h Konfigurace HW a datove struktury
*
* Snimac: ZTS-3000-FSJT-N01 (polykarbonatovy 3-pohárový anemometr)
* - RS485, Modbus-RTU, 8N1, vychozi 4800 bps
* - Vychozi adresa: 0x01 (1)
* - 2 vstupni registry (FC 0x03):
* reg 0x0000 = rychlost vetru x10 (napr. 0x0024 = 36 = 3.6 m/s)
* reg 0x0001 = sila vetru (Beaufort 0-17)
* - Zmena adresy: zapis (FC 0x06) do reg 0x07D0
*
* POZOR: Konfigurace HW pinu je pevna (nelze menit pres web).
* Pres webove rozhrani lze menit pouze komunikacni parametry
* a adresu snimace.
*/
#include <Arduino.h>
#include <Preferences.h>
// ─── ETH (LAN8720 na ESPlan) ──────────────────────────────────────────────────
#define ETH_PHY_TYPE ETH_PHY_LAN8720
#define ETH_PHY_ADDR 0
#define ETH_PHY_MDC 23
#define ETH_PHY_MDIO 18
#define ETH_PHY_POWER -1
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
// ─── RS485 / UART ─────────────────────────────────────────────────────────────
#define PIN_RS485_RX 36
#define PIN_RS485_TX 4
// DE pin je rizen hardwarove nepotrebujeme softwarove ovladani
// ─── Modbus vychozi hodnoty ───────────────────────────────────────────────────
#define DEFAULT_MODBUS_ADDR 1
#define DEFAULT_BAUD_RATE 4800 // datasheet ZTS-3000-FSJT-N01
#define DEFAULT_POLL_INTERVAL 5 // sekundy
// ─── Modbus registry snimace (ZTS-3000-FSJT-N01) ─────────────────────────────
// Cteni (FC03):
#define REG_WIND_SPEED 0x0000 // rychlost vetru * 10 (m/s)
#define REG_WIND_LEVEL 0x0001 // sila vetru (Beaufort 0-17)
#define REG_BLOCK_COUNT 2 // celkem 2 registry
// Zapis (FC06):
#define REG_DEVICE_ADDR 0x07D0 // adresa snimace (1-247)
// ─── Flag pritomnosti snimace ────────────────────────────────────────────────
struct SensorPresence {
bool wind = false;
};
// ─── Namerene hodnoty ─────────────────────────────────────────────────────────
struct SensorData {
SensorPresence present;
float windSpeed = 0.0f; // m/s
uint16_t windLevel = 0; // Beaufort 0-17
uint32_t lastUpdateMs = 0;
bool commError = false;
String errorMsg = "";
};
// ─── Konfigurace snimace (uklada se do NVS) ───────────────────────────────────
struct SensorConfig {
uint8_t modbusAddr = DEFAULT_MODBUS_ADDR;
uint32_t baudRate = DEFAULT_BAUD_RATE;
uint8_t pollInterval = DEFAULT_POLL_INTERVAL;
};
// ─── NVS load / save ──────────────────────────────────────────────────────────
inline void loadConfig(Preferences &p, SensorConfig &c) {
c.modbusAddr = p.getUChar("mbAddr", DEFAULT_MODBUS_ADDR);
c.baudRate = p.getUInt ("baud", DEFAULT_BAUD_RATE);
c.pollInterval = p.getUChar("poll", DEFAULT_POLL_INTERVAL);
}
inline void saveConfig(Preferences &p, const SensorConfig &c) {
p.putUChar("mbAddr", c.modbusAddr);
p.putUInt ("baud", c.baudRate);
p.putUChar("poll", c.pollInterval);
}
// ─── Tabulka Beaufortovy stupnice (cz/en nazev a popis) ──────────────────────
// Podle datasheetu ZTS-3000 (0..17). Hodnoty rozsahu m/s a popisy lze ziskat
// pres funkce nize. Tento snimac ovsem rovnou vraci stupen v reg 0x0001.
struct BeaufortInfo {
uint8_t level;
const char* nameCz; // cesky nazev
const char* nameEn; // anglicky nazev
};
inline const BeaufortInfo& beaufortInfo(uint8_t lvl) {
static const BeaufortInfo TBL[] = {
{ 0, "Bezvetri", "Calm" },
{ 1, "Vanek", "Light air" },
{ 2, "Slaby vitr", "Light breeze" },
{ 3, "Mirny vitr", "Gentle breeze" },
{ 4, "Dosti silny vitr", "Moderate breeze" },
{ 5, "Cerstvy vitr", "Fresh breeze" },
{ 6, "Silny vitr", "Strong breeze" },
{ 7, "Prudky vitr", "Near gale" },
{ 8, "Bourlivy vitr", "Gale" },
{ 9, "Vichrice", "Strong gale" },
{10, "Silna vichrice", "Storm" },
{11, "Mohutna vichrice", "Violent storm" },
{12, "Orkan", "Hurricane" },
{13, "Orkan", "Hurricane" },
{14, "Orkan", "Hurricane" },
{15, "Orkan", "Hurricane" },
{16, "Orkan", "Hurricane" },
{17, "Orkan", "Hurricane" },
};
if (lvl > 17) lvl = 17;
return TBL[lvl];
}
#endif // WS_CFG_H

View File

@ -0,0 +1,158 @@
#ifndef WS_I18N_STR_H
#define WS_I18N_STR_H
/*
* i18n_str.h Preklady webove rozhrani (CZ / EN)
* pro snimac ZTS-3000-FSJT-N01
*/
#include <Arduino.h>
struct I18nStrings {
// Navigace
const char* title;
const char* navStatus;
const char* navConfig;
const char* langSwitch;
// Stavova stranka
const char* pageStatusTitle;
const char* sectionSensors;
const char* sectionValues;
const char* colSensor;
const char* colPresent;
const char* colValue;
const char* colUnit;
const char* yes;
const char* no;
const char* lastUpdate;
const char* noData;
const char* commError;
const char* btnDetect;
// Nazvy mereni
const char* snsWind;
const char* snsWindSpeed;
const char* snsWindLevel;
const char* snsWindLevelName;
const char* unitMs;
const char* unitBft;
// Konfigurace
const char* pageConfigTitle;
const char* sectionComm;
const char* cfgAddr;
const char* cfgAddrHelp;
const char* cfgBaud;
const char* cfgBaudHelp;
const char* cfgPoll;
const char* cfgPollHelp;
const char* sectionActions;
const char* btnChangeAddr;
const char* btnChangeAddrHelp;
const char* cfgNewAddr;
const char* btnSave;
const char* sectionRaw;
};
// ─── Cestina ──────────────────────────────────────────────────────────────────
static const I18nStrings STR_CZ = {
"ESPlan ZTS-3000 Anemometr",
"Stav snimace",
"Konfigurace",
"English",
"Stav a hodnoty snimace",
"Detekce snimace",
"Namerene hodnoty",
"Velicina",
"Pritomnost",
"Hodnota",
"Jednotka",
"&#10003; Pritomno",
"&#8212; Nepritomno",
"Posledni aktualizace",
"&mdash;",
"&#9888; Chyba komunikace",
"Znovu detekovat",
"Anemometr ZTS-3000",
"Rychlost vetru",
"Sila vetru (Beaufort)",
"Slovni popis",
"m/s",
"Bft",
"Konfigurace snimace",
"Komunikacni parametry",
"Modbus adresa",
"Aktualni adresa snimace na sbernici RS485 (1-247, vychozi: 1)",
"Prenosova rychlost",
"Baudrate RS485 (datasheet: 4800 bps)",
"Interval cteni",
"Interval periodickeho cteni dat v sekundach (1-60)",
"Akce snimace",
"Zmenit adresu snimace",
"Zapise novou adresu do reg. 0x07D0 snimace (FC06). Pote upravte adresu vyse.",
"Nova adresa",
"Ulozit konfiguraci",
"RS485 komunikace (raw)"
};
// ─── Anglictina ───────────────────────────────────────────────────────────────
static const I18nStrings STR_EN = {
"ESPlan ZTS-3000 Anemometer",
"Sensor Status",
"Configuration",
"Cesky",
"Sensor Status &amp; Readings",
"Sensor Detection",
"Live Readings",
"Quantity",
"Presence",
"Value",
"Unit",
"&#10003; Present",
"&#8212; Not present",
"Last update",
"&mdash;",
"&#9888; Communication error",
"Re-detect sensor",
"ZTS-3000 anemometer",
"Wind speed",
"Wind force (Beaufort)",
"Description",
"m/s",
"Bft",
"Sensor Configuration",
"Communication Settings",
"Modbus address",
"Current sensor address on the RS485 bus (1-247, default: 1)",
"Baud rate",
"RS485 baud rate (datasheet: 4800 bps)",
"Poll interval",
"Data polling interval in seconds (1-60)",
"Sensor Actions",
"Change sensor address",
"Writes the new address into register 0x07D0 (FC06). Update the address above afterwards.",
"New address",
"Save configuration",
"RS485 Communication (raw)"
};
inline const I18nStrings& getStrings(bool isCz) {
return isCz ? STR_CZ : STR_EN;
}
#endif // WS_I18N_STR_H

View File

@ -0,0 +1,152 @@
/*
* mb_client.cpp Implementace Modbus RTU klienta
*/
#include "mb_client.h"
uint16_t ModbusClient::crc16(const uint8_t* data, uint8_t len) {
uint16_t crc = 0xFFFF;
for (uint8_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i];
for (uint8_t b = 0; b < 8; b++) {
if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001;
else crc >>= 1;
}
}
return crc;
}
void ModbusClient::flushRx() {
while (_serial->available()) _serial->read();
}
void ModbusClient::begin(uint32_t baud, uint8_t /*addr*/) {
_baud = baud;
_serial = &Serial2;
_serial->begin(_baud, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
Serial.printf("[MOD] RS485 init: %d baud, 8N1, RX=%d TX=%d\n",
_baud, PIN_RS485_RX, PIN_RS485_TX);
}
void ModbusClient::setBaudRate(uint32_t baud) {
_baud = baud;
_serial->end();
_serial->begin(_baud, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
Serial.printf("[MOD] Baudrate zmenen na: %d\n", _baud);
}
ModbusStatus ModbusClient::readRegisters(uint8_t slaveAddr, uint16_t startReg,
uint8_t count, uint16_t* out) {
if (!_serial || count == 0 || count > 64) return ModbusStatus::FRAME_ERROR;
uint8_t req[8];
req[0] = slaveAddr;
req[1] = 0x03;
req[2] = (startReg >> 8) & 0xFF;
req[3] = startReg & 0xFF;
req[4] = 0x00;
req[5] = count;
uint16_t c = crc16(req, 6);
req[6] = c & 0xFF;
req[7] = (c >> 8) & 0xFF;
// Ulozit TX
memcpy(lastTx, req, 8);
lastTxLen = 8;
flushRx();
_serial->write(req, 8);
_serial->flush();
uint8_t expectedLen = 3 + count * 2 + 2;
uint8_t buf[160];
uint8_t rxLen = 0;
uint32_t t0 = millis();
while (rxLen < expectedLen && (millis() - t0 < TIMEOUT_MS)) {
if (_serial->available()) buf[rxLen++] = _serial->read();
yield();
}
// Ulozit RX
uint8_t copyLen = (rxLen < sizeof(lastRx)) ? rxLen : sizeof(lastRx);
memcpy(lastRx, buf, copyLen);
lastRxLen = copyLen;
if (rxLen < expectedLen) {
lastResult = ModbusStatus::TIMEOUT;
return ModbusStatus::TIMEOUT;
}
uint16_t rxCrc = (uint16_t)buf[rxLen-1] << 8 | buf[rxLen-2];
uint16_t calcCrc = crc16(buf, rxLen - 2);
if (rxCrc != calcCrc) {
lastResult = ModbusStatus::CRC_ERROR;
return ModbusStatus::CRC_ERROR;
}
if (buf[1] & 0x80) {
lastResult = ModbusStatus::EXCEPTION;
return ModbusStatus::EXCEPTION;
}
lastResult = ModbusStatus::OK;
for (uint8_t i = 0; i < count; i++) {
out[i] = (uint16_t)buf[3 + i*2] << 8 | buf[4 + i*2];
}
return ModbusStatus::OK;
}
ModbusStatus ModbusClient::writeRegister(uint8_t slaveAddr, uint16_t reg, uint16_t value) {
if (!_serial) return ModbusStatus::FRAME_ERROR;
uint8_t req[8];
req[0] = slaveAddr;
req[1] = 0x06;
req[2] = (reg >> 8) & 0xFF;
req[3] = reg & 0xFF;
req[4] = (value >> 8) & 0xFF;
req[5] = value & 0xFF;
uint16_t c = crc16(req, 6);
req[6] = c & 0xFF;
req[7] = (c >> 8) & 0xFF;
memcpy(lastTx, req, 8);
lastTxLen = 8;
flushRx();
_serial->write(req, 8);
_serial->flush();
uint8_t buf[8];
uint8_t rxLen = 0;
uint32_t t0 = millis();
while (rxLen < 8 && (millis() - t0 < TIMEOUT_MS)) {
if (_serial->available()) buf[rxLen++] = _serial->read();
yield();
}
memcpy(lastRx, buf, rxLen);
lastRxLen = rxLen;
if (rxLen < 8) { lastResult = ModbusStatus::TIMEOUT; return ModbusStatus::TIMEOUT; }
uint16_t rxCrc = (uint16_t)buf[7] << 8 | buf[6];
uint16_t calcCrc = crc16(buf, 6);
if (rxCrc != calcCrc) { lastResult = ModbusStatus::CRC_ERROR; return ModbusStatus::CRC_ERROR; }
lastResult = ModbusStatus::OK;
return ModbusStatus::OK;
}
const char* ModbusClient::statusStr(ModbusStatus s) {
switch (s) {
case ModbusStatus::OK: return "OK";
case ModbusStatus::TIMEOUT: return "TIMEOUT";
case ModbusStatus::CRC_ERROR: return "CRC_ERROR";
case ModbusStatus::EXCEPTION: return "EXCEPTION";
case ModbusStatus::FRAME_ERROR: return "FRAME_ERROR";
default: return "UNKNOWN";
}
}

View File

@ -0,0 +1,54 @@
#ifndef WS_MB_CLIENT_H
#define WS_MB_CLIENT_H
/*
* mb_client.h Modbus RTU klient pres RS485 / HardwareSerial
*
* FC03 Read Holding Registers
* FC06 Write Single Register
*
* DE pin (direction enable) rizen HW nevyzaduje softwarove prepinani.
*/
#include <Arduino.h>
#include "cfg.h"
enum class ModbusStatus : uint8_t {
OK = 0,
TIMEOUT,
CRC_ERROR,
EXCEPTION,
FRAME_ERROR
};
class ModbusClient {
public:
ModbusClient() = default;
void begin(uint32_t baud = 4800, uint8_t addr = 1);
ModbusStatus readRegisters(uint8_t slaveAddr, uint16_t startReg,
uint8_t count, uint16_t* out);
ModbusStatus writeRegister(uint8_t slaveAddr, uint16_t reg, uint16_t value);
void setBaudRate(uint32_t baud);
const char* statusStr(ModbusStatus s);
// ── Posledni TX/RX frame ────────────────────────────────────────────
uint8_t lastTx[16];
uint8_t lastTxLen = 0;
uint8_t lastRx[80];
uint8_t lastRxLen = 0;
ModbusStatus lastResult = ModbusStatus::OK;
private:
HardwareSerial* _serial = nullptr;
uint32_t _baud = 4800;
static const uint16_t TIMEOUT_MS = 500;
uint16_t crc16(const uint8_t* data, uint8_t len);
void flushRx();
};
#endif // WS_MB_CLIENT_H

View File

@ -0,0 +1,85 @@
/*
* snr.cpp Detekce a cteni snimace ZTS-3000-FSJT-N01
*
* Snimac ma 2 registry pristupne pres FC03:
* reg 0x0000 = rychlost vetru x10 (m/s)
* reg 0x0001 = sila vetru (Beaufort 0-17)
*
* Detekce: pokud cteni 2 registru projde (CRC OK, zadna exception),
* snimac povazujeme za pritomny. Bezvetri (0,0) je validni stav.
*/
#include "snr.h"
// ─── Detekce ──────────────────────────────────────────────────────────────────
void detectSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg) {
SensorPresence& p = data.present;
uint8_t addr = cfg.modbusAddr;
p = SensorPresence(); // reset
Serial.println(F("[SNS] === Detekce ZTS-3000-FSJT-N01 ==="));
uint16_t r[REG_BLOCK_COUNT] = {0};
ModbusStatus st = mb.readRegisters(addr, REG_WIND_SPEED, REG_BLOCK_COUNT, r);
if (st != ModbusStatus::OK) {
Serial.printf("[SNS] Chyba cteni: %s\n", mb.statusStr(st));
data.commError = true;
data.errorMsg = mb.statusStr(st);
return;
}
Serial.printf("[SNS] Cteni: speed_raw=%u (=%.1f m/s) level=%u\n",
r[0], r[0] / 10.0f, r[1]);
data.commError = false;
data.errorMsg = "";
p.wind = true;
// rovnou ulozime hodnoty
data.windSpeed = r[0] / 10.0f;
data.windLevel = r[1];
data.lastUpdateMs = millis();
Serial.println(F("[SNS] === Detekce dokoncena: snimac pritomny ==="));
}
// ─── Cteni snimace ────────────────────────────────────────────────────────────
void readAllSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg) {
uint16_t r[REG_BLOCK_COUNT] = {0};
ModbusStatus st = mb.readRegisters(cfg.modbusAddr, REG_WIND_SPEED,
REG_BLOCK_COUNT, r);
if (st != ModbusStatus::OK) {
data.commError = true;
data.errorMsg = mb.statusStr(st);
Serial.printf("[SNS] Chyba cteni: %s\n", mb.statusStr(st));
return;
}
data.commError = false;
data.errorMsg = "";
data.lastUpdateMs = millis();
// Pokud predtim selhala detekce, oznacime snimac jako pritomny
if (!data.present.wind) data.present.wind = true;
data.windSpeed = r[0] / 10.0f;
data.windLevel = r[1];
}
// ─── Vypis stavu ─────────────────────────────────────────────────────────────
void printSensorStatus(const SensorData& data) {
const SensorPresence& p = data.present;
Serial.println(F("[SNS] +--------------------------+"));
Serial.println(F("[SNS] | ZTS-3000-FSJT-N01 |"));
Serial.println(F("[SNS] +--------------------------+"));
Serial.printf ("[SNS] | Snimac pritomny: %-3s |\n", p.wind ? "ANO" : "NE");
if (p.wind) {
const BeaufortInfo& bi = beaufortInfo(data.windLevel);
Serial.printf("[SNS] | Rychlost: %5.1f m/s |\n", data.windSpeed);
Serial.printf("[SNS] | Sila: %u (%s) |\n", data.windLevel, bi.nameCz);
}
Serial.println(F("[SNS] +--------------------------+"));
}

View File

@ -0,0 +1,14 @@
#ifndef WS_SNR_H
#define WS_SNR_H
/*
* snr.h Detekce a cteni snimace ZTS-3000-FSJT-N01
*/
#include "cfg.h"
#include "mb_client.h"
void detectSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg);
void readAllSensors(ModbusClient& mb, SensorData& data, const SensorConfig& cfg);
void printSensorStatus(const SensorData& data);
#endif // WS_SNR_H

View File

@ -0,0 +1,592 @@
/*
* ws_server.cpp HTTP webserver implementace
* ZTS-3000-FSJT-N01
*/
#include "ws_server.h"
#include <ETH.h>
// ─── Globalni pointery ────────────────────────────────────────────────────────
static WebServer* gServer = nullptr;
static SensorData* gData = nullptr;
static SensorConfig* gCfg = nullptr;
static Preferences* gPrefs = nullptr;
static ModbusClient* gMb = nullptr;
// ─── Pomocne ─────────────────────────────────────────────────────────────────
static bool isLangCz() {
if (gServer->hasArg("lang")) {
String l = gServer->arg("lang");
return !(l == "en");
}
if (gServer->hasHeader("Cookie")) {
String c = gServer->header("Cookie");
if (c.indexOf("lang=en") >= 0) return false;
}
return true;
}
static void sendLangCookie(bool cz) {
gServer->sendHeader("Set-Cookie",
cz ? "lang=cs; Path=/; Max-Age=31536000"
: "lang=en; Path=/; Max-Age=31536000");
}
static String fmtFloat(float v, int dec = 1) {
char buf[20];
snprintf(buf, sizeof(buf), "%.*f", dec, v);
return String(buf);
}
// ─── CSS ─────────────────────────────────────────────────────────────────────
static const char CSS[] PROGMEM = R"CSS(
:root{
--bg:#0d1117;--bg2:#161b22;--bg3:#21262d;
--border:#30363d;--border2:#484f58;
--text:#e6edf3;--text2:#8b949e;--text3:#6e7681;
--accent:#58a6ff;--accent2:#1f6feb;
--green:#3fb950;--red:#f85149;--yellow:#d29922;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;
min-height:100vh;font-size:14px;line-height:1.5}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
.topbar{background:var(--bg2);border-bottom:1px solid var(--border);
padding:0 24px;display:flex;align-items:center;height:52px;
position:sticky;top:0;z-index:100}
.logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;
color:var(--text);margin-right:32px;white-space:nowrap}
.logo-icon{width:28px;height:28px;background:#1f6feb;border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0}
.nav-links{display:flex;gap:2px;flex:1}
.nav-link{padding:6px 14px;border-radius:6px;font-size:13px;
color:var(--text2);transition:background .15s,color .15s;white-space:nowrap}
.nav-link:hover{background:var(--bg3);color:var(--text);text-decoration:none}
.nav-link.active{background:var(--accent2);color:#fff}
.lang-btn{margin-left:auto;padding:5px 12px;border-radius:20px;
background:var(--bg3);border:1px solid var(--border2);
color:var(--text2);font-size:12px;white-space:nowrap}
.lang-btn:hover{background:var(--border);color:var(--text);text-decoration:none}
.main{max-width:1100px;margin:0 auto;padding:28px 20px}
h1{font-size:20px;font-weight:600;margin-bottom:20px}
h2{font-size:12px;font-weight:600;color:var(--text3);text-transform:uppercase;
letter-spacing:.08em;margin:28px 0 12px;padding-bottom:8px;
border-bottom:1px solid var(--border)}
h2:first-of-type{margin-top:0}
.card{background:var(--bg2);border:1px solid var(--border);
border-radius:10px;padding:16px 20px;margin-bottom:16px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{padding:8px 12px;text-align:left;font-weight:600;font-size:11px;
color:var(--text3);text-transform:uppercase;letter-spacing:.06em;
border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,.02)}
.val{font-family:'Courier New',monospace;font-size:13px;color:var(--text)}
.unit{color:var(--text3);font-size:12px}
.badge-yes{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-size:12px}
.badge-no{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:12px}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot-g{background:var(--green);box-shadow:0 0 6px var(--green);
animation:pulse 2s ease-in-out infinite}
.dot-x{background:var(--text3)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.toolbar{display:flex;align-items:center;gap:8px;margin-bottom:16px;flex-wrap:wrap}
.smeta{margin-left:auto;font-size:12px;color:var(--text3);font-family:'Courier New',monospace}
.err-banner{background:rgba(248,81,73,.1);border:1px solid var(--red);
border-radius:8px;padding:10px 16px;color:var(--red);font-size:13px;
margin-bottom:16px;display:flex;align-items:center;gap:8px}
.ip-bar{display:flex;align-items:center;gap:20px;padding:10px 16px;
background:var(--bg3);border:1px solid var(--border);border-radius:8px;
font-family:'Courier New',monospace;font-size:12px;color:var(--text2);
margin-bottom:20px;flex-wrap:wrap}
.ip-lbl{color:var(--text3)}
.btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;
border-radius:6px;font-size:13px;cursor:pointer;border:1px solid transparent;
font-family:inherit;transition:background .15s;text-decoration:none}
.btn:active{opacity:.8}
.btn-p{background:var(--accent2);color:#fff;border-color:var(--accent2)}
.btn-p:hover{background:#388bfd;text-decoration:none;color:#fff}
.btn-d{background:var(--bg3);color:var(--text);border-color:var(--border2)}
.btn-d:hover{background:var(--border);text-decoration:none}
.btn-w{background:transparent;color:var(--yellow);border-color:var(--yellow)}
.btn-w:hover{background:rgba(210,153,34,.1);text-decoration:none}
.btn-sm{padding:4px 10px;font-size:12px}
.fg{margin-bottom:18px}
label{display:block;font-size:13px;font-weight:500;color:var(--text2);margin-bottom:5px}
.ht{font-size:11px;color:var(--text3);margin-top:4px}
input[type=number],input[type=text],select{
background:var(--bg3);border:1px solid var(--border2);border-radius:6px;
padding:7px 10px;color:var(--text);font-size:13px;width:100%;
max-width:280px;outline:none;transition:border-color .15s;font-family:inherit}
input:focus,select:focus{border-color:var(--accent)}
select option{background:var(--bg3)}
.frow{display:flex;gap:16px;flex-wrap:wrap}
.frow .fg{flex:1;min-width:200px}
.act-row{display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start}
.act-item{flex:1;min-width:200px}
.live-dot{width:8px;height:8px;border-radius:50%;background:var(--green);
box-shadow:0 0 6px var(--green);animation:pulse 2s ease-in-out infinite;
flex-shrink:0;margin-left:auto}
.val.flash{animation:valflash .4s ease-out}
@keyframes valflash{0%{color:var(--accent)}100%{color:var(--text)}}
.raw-panel{background:var(--bg2);border:1px solid var(--border);border-radius:10px;
padding:14px 20px;margin-bottom:16px;font-family:'Courier New',monospace;
font-size:12px;line-height:1.8}
.raw-tx{color:var(--yellow)}
.raw-rx{color:var(--green)}
.raw-err{color:var(--red)}
.raw-label{color:var(--text3);display:inline-block;width:28px}
.raw-status{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:600;text-transform:uppercase;margin-left:8px}
.raw-status.ok{background:rgba(63,185,80,.15);color:var(--green)}
.raw-status.err{background:rgba(248,81,73,.15);color:var(--red)}
.bigval{display:flex;align-items:baseline;gap:8px;margin:4px 0}
.bigval .num{font-size:34px;font-weight:700;color:var(--accent);font-family:'Courier New',monospace}
.bigval .un{font-size:14px;color:var(--text2)}
@media(max-width:640px){
.topbar{padding:0 12px}
.logo{margin-right:12px}
.main{padding:16px 12px}
th,td{padding:7px 8px}
input,select{max-width:100%}
.smeta{margin-left:0}
}
)CSS";
// ─── HTML head + nav ─────────────────────────────────────────────────────────
static String htmlHead(const I18nStrings& s, bool cz, const char* page) {
String langUrl = cz ? "?lang=en" : "?lang=cs";
String out;
out.reserve(3000);
out += F("<!DOCTYPE html><html lang='");
out += cz ? "cs" : "en";
out += F("'><head><meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>");
out += s.title;
out += F("</title><style>");
out += FPSTR(CSS);
out += F("</style></head><body>"
"<nav class='topbar'>"
"<div class='logo'>"
"<div class='logo-icon'>"
"<svg width='18' height='18' viewBox='0 0 18 18' fill='none'>"
"<circle cx='9' cy='9' r='3' stroke='#58a6ff' stroke-width='1.5'/>"
"<line x1='9' y1='2' x2='9' y2='5' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"<line x1='9' y1='13' x2='9' y2='16' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"<line x1='2' y1='9' x2='5' y2='9' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"<line x1='13' y1='9' x2='16' y2='9' stroke='#58a6ff' stroke-width='1.5' stroke-linecap='round'/>"
"</svg></div>");
out += s.title;
out += F("</div><div class='nav-links'>");
out += F("<a href='/' class='nav-link");
if (strcmp(page,"status")==0) out += F(" active");
out += F("'>");
out += s.navStatus;
out += F("</a><a href='/config' class='nav-link");
if (strcmp(page,"config")==0) out += F(" active");
out += F("'>");
out += s.navConfig;
out += F("</a></div><a href='");
out += langUrl;
out += F("' class='lang-btn'>");
out += s.langSwitch;
out += F("</a></nav><div class='main'>");
return out;
}
static String htmlFoot() {
return F("</div></body></html>");
}
// ─── Stranka: Stav ────────────────────────────────────────────────────────────
static void handleRoot() {
bool cz = isLangCz();
sendLangCookie(cz);
const I18nStrings& s = getStrings(cz);
String html = htmlHead(s, cz, "status");
// IP bar
html += F("<div class='ip-bar'>"
"<span><span class='ip-lbl'>IP: </span><b>");
html += ETH.localIP().toString();
html += F("</b></span><span><span class='ip-lbl'>MAC: </span>");
html += ETH.macAddress();
html += F("</span><span><span class='ip-lbl'>ETH: </span>");
html += ETH.linkSpeed();
html += F(" Mbit/s</span></div>");
if (gData->commError) {
html += F("<div class='err-banner'>&#9888; ");
html += s.commError;
html += F(": ");
html += gData->errorMsg;
html += F("</div>");
}
// Toolbar
html += F("<div class='toolbar'>"
"<form method='POST' action='/detect' style='display:inline'>"
"<button type='submit' class='btn btn-d btn-sm'>");
html += s.btnDetect;
html += F("</button></form>"
"<span class='live-dot'></span>"
"<span id='updInfo' class='smeta'></span>");
html += F("</div>");
// Tabulka detekce
html += F("<h2>");
html += s.sectionSensors;
html += F("</h2><div class='card'><table>"
"<thead><tr><th>");
html += s.colSensor;
html += F("</th><th>");
html += s.colPresent;
html += F("</th></tr></thead><tbody>");
const SensorPresence& p = gData->present;
html += F("<tr><td>");
html += s.snsWind;
html += F("</td><td>");
if (p.wind) {
html += F("<span class='badge-yes'><span class='dot dot-g'></span>");
html += s.yes;
} else {
html += F("<span class='badge-no'><span class='dot dot-x'></span>");
html += s.no;
}
html += F("</span></td></tr>");
html += F("</tbody></table></div>");
// Tabulka hodnot
html += F("<h2>");
html += s.sectionValues;
html += F("</h2><div class='card'><table><thead><tr><th>");
html += s.colSensor;
html += F("</th><th>");
html += s.colValue;
html += F("</th><th>");
html += s.colUnit;
html += F("</th></tr></thead><tbody>");
const SensorData& d = *gData;
const BeaufortInfo& bi = beaufortInfo(d.windLevel);
const char* bName = cz ? bi.nameCz : bi.nameEn;
// Rychlost vetru
html += F("<tr><td>");
html += s.snsWindSpeed;
html += F("</td><td class='val' id='v_ws'>");
html += p.wind ? fmtFloat(d.windSpeed) : String(s.noData);
html += F("</td><td class='unit'>");
html += p.wind ? s.unitMs : "";
html += F("</td></tr>");
// Sila vetru
html += F("<tr><td>");
html += s.snsWindLevel;
html += F("</td><td class='val' id='v_wl'>");
html += p.wind ? String(d.windLevel) : String(s.noData);
html += F("</td><td class='unit'>");
html += p.wind ? s.unitBft : "";
html += F("</td></tr>");
// Slovni popis
html += F("<tr><td>");
html += s.snsWindLevelName;
html += F("</td><td class='val' id='v_wn'>");
html += p.wind ? bName : s.noData;
html += F("</td><td class='unit'></td></tr>");
html += F("</tbody></table></div>");
// ── Raw RS485 panel ────────────────────────────────────────────────────
html += F("<h2>");
html += s.sectionRaw;
html += F("</h2><div class='raw-panel'>"
"<div><span class='raw-label'>TX:</span> <span class='raw-tx' id='rawTx'>...</span>"
" <span class='raw-status' id='rawSt'></span></div>"
"<div><span class='raw-label'>RX:</span> <span class='raw-rx' id='rawRx'>...</span></div>"
"</div>");
// Inline JavaScript AJAX auto-refresh
html += F("<script>"
"var BCZ=['Bezvetri','Vanek','Slaby vitr','Mirny vitr','Dosti silny vitr',"
"'Cerstvy vitr','Silny vitr','Prudky vitr','Bourlivy vitr','Vichrice',"
"'Silna vichrice','Mohutna vichrice','Orkan','Orkan','Orkan','Orkan','Orkan','Orkan'];"
"var BEN=['Calm','Light air','Light breeze','Gentle breeze','Moderate breeze',"
"'Fresh breeze','Strong breeze','Near gale','Gale','Strong gale','Storm',"
"'Violent storm','Hurricane','Hurricane','Hurricane','Hurricane','Hurricane','Hurricane'];"
"var LANG=document.documentElement.lang||'cs';"
"function bName(l){var t=(LANG==='en'?BEN:BCZ);return t[l<0?0:(l>17?17:l)];}"
"function u(id,v){"
"var e=document.getElementById(id);"
"if(!e)return;"
"var s=String(v);"
"if(e.textContent!==s){"
"e.textContent=s;"
"e.classList.remove('flash');"
"void e.offsetWidth;"
"e.classList.add('flash');"
"}"
"}"
"function updRaw(){"
"fetch('/api/raw').then(function(r){return r.json()}).then(function(d){"
"var tx=document.getElementById('rawTx');"
"var rx=document.getElementById('rawRx');"
"var st=document.getElementById('rawSt');"
"if(tx)tx.textContent=d.tx||'-';"
"if(rx){rx.textContent=d.rx||'-';"
"rx.className=(d.s==='OK')?'raw-rx':'raw-err';}"
"if(st){st.textContent=d.s;"
"st.className='raw-status '+(d.s==='OK'?'ok':'err');}"
"}).catch(function(){});"
"}"
"function upd(){"
"fetch('/api/data').then(function(r){return r.json()}).then(function(d){"
"if(d.err){"
"document.getElementById('updInfo').textContent='\\u26A0 error';"
"return;"
"}"
"if(d.windSpeed!==undefined){"
"u('v_ws',d.windSpeed.toFixed(1));"
"u('v_wl',d.windLevel);"
"u('v_wn',bName(d.windLevel));"
"}"
"document.getElementById('updInfo').textContent="
"new Date().toLocaleTimeString();"
"}).catch(function(){"
"document.getElementById('updInfo').textContent='\\u26A0 offline';"
"});"
"}"
"function tick(){upd();updRaw();}"
"tick();setInterval(tick,2000);"
"</script>");
html += htmlFoot();
gServer->send(200, "text/html; charset=UTF-8", html);
}
// ─── POST /detect ─────────────────────────────────────────────────────────────
static void handleDetect() {
detectSensors(*gMb, *gData, *gCfg);
bool cz = isLangCz();
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/?lang=cs" : "/?lang=en");
gServer->send(303);
}
// ─── GET /config ──────────────────────────────────────────────────────────────
static void handleConfig() {
bool cz = isLangCz();
sendLangCookie(cz);
const I18nStrings& s = getStrings(cz);
String html = htmlHead(s, cz, "config");
html += F("<h1>");
html += s.pageConfigTitle;
html += F("</h1><form method='POST' action='/config'>");
html += F("<input type='hidden' name='lang' value='");
html += cz ? "cs" : "en";
html += F("'>");
// Komunikace
html += F("<h2>");
html += s.sectionComm;
html += F("</h2><div class='card'><div class='frow'>");
html += F("<div class='fg'><label>");
html += s.cfgAddr;
html += F("</label><input type='number' name='addr' min='1' max='247' value='");
html += gCfg->modbusAddr;
html += F("'><div class='ht'>");
html += s.cfgAddrHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgBaud;
html += F("</label><select name='baud'>");
const uint32_t bauds[] = {1200,2400,4800,9600,19200,38400,57600,115200};
for (auto b : bauds) {
html += F("<option value='");
html += b;
html += F("'");
if (b == gCfg->baudRate) html += F(" selected");
html += F(">");
html += b;
html += F(" bps</option>");
}
html += F("</select><div class='ht'>");
html += s.cfgBaudHelp;
html += F("</div></div>");
html += F("<div class='fg'><label>");
html += s.cfgPoll;
html += F("</label><input type='number' name='poll' min='1' max='60' value='");
html += gCfg->pollInterval;
html += F("'><div class='ht'>");
html += s.cfgPollHelp;
html += F("</div></div></div></div>");
html += F("<div style='margin-top:8px'>"
"<button type='submit' class='btn btn-p'>");
html += s.btnSave;
html += F("</button></div></form>");
// Akce zmena adresy snimace
html += F("<h2>");
html += s.sectionActions;
html += F("</h2><div class='card'>"
"<form method='POST' action='/action'>"
"<input type='hidden' name='act' value='change_addr'>"
"<input type='hidden' name='lang' value='");
html += cz ? "cs" : "en";
html += F("'><div class='fg'><label>");
html += s.cfgNewAddr;
html += F("</label><input type='number' name='newAddr' min='1' max='247' value='");
html += gCfg->modbusAddr;
html += F("'></div>"
"<button type='submit' class='btn btn-w'>");
html += s.btnChangeAddr;
html += F("</button><div class='ht' style='margin-top:6px'>");
html += s.btnChangeAddrHelp;
html += F("</div></form></div>");
html += htmlFoot();
gServer->send(200, "text/html; charset=UTF-8", html);
}
// ─── POST /config ─────────────────────────────────────────────────────────────
static void handleConfigPost() {
bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true;
if (gServer->hasArg("addr")) {
gCfg->modbusAddr = (uint8_t)constrain(gServer->arg("addr").toInt(), 1, 247);
}
if (gServer->hasArg("baud")) {
uint32_t b = (uint32_t)gServer->arg("baud").toInt();
if (b != gCfg->baudRate) { gCfg->baudRate = b; gMb->setBaudRate(b); }
}
if (gServer->hasArg("poll")) {
gCfg->pollInterval = (uint8_t)constrain(gServer->arg("poll").toInt(), 1, 60);
}
gPrefs->begin("zts3000", false);
saveConfig(*gPrefs, *gCfg);
gPrefs->end();
Serial.println(F("[CFG] Konfigurace ulozena."));
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en");
gServer->send(303);
}
// ─── POST /action ─────────────────────────────────────────────────────────────
static void handleAction() {
bool cz = gServer->hasArg("lang") ? (gServer->arg("lang") != "en") : true;
String act = gServer->arg("act");
if (act == "change_addr" && gServer->hasArg("newAddr")) {
uint8_t newAddr = (uint8_t)constrain(gServer->arg("newAddr").toInt(), 1, 247);
ModbusStatus st = gMb->writeRegister(gCfg->modbusAddr, REG_DEVICE_ADDR, newAddr);
Serial.printf("[ACT] Change addr %u -> %u : %s\n",
gCfg->modbusAddr, newAddr, gMb->statusStr(st));
if (st == ModbusStatus::OK) {
gCfg->modbusAddr = newAddr;
gPrefs->begin("zts3000", false);
saveConfig(*gPrefs, *gCfg);
gPrefs->end();
}
}
sendLangCookie(cz);
gServer->sendHeader("Location", cz ? "/config?lang=cs" : "/config?lang=en");
gServer->send(303);
}
// ─── GET /api/data ────────────────────────────────────────────────────────────
static void handleApiData() {
const SensorData& d = *gData;
String json = "{";
json += "\"ts\":" + String(d.lastUpdateMs) + ",";
json += "\"err\":" + String(d.commError ? "true" : "false");
if (d.present.wind) {
json += ",\"windSpeed\":" + fmtFloat(d.windSpeed);
json += ",\"windLevel\":" + String(d.windLevel);
}
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── GET /api/status ──────────────────────────────────────────────────────────
static void handleApiStatus() {
const SensorPresence& p = gData->present;
String json = "{";
json += "\"wind\":" + String(p.wind ? "true" : "false");
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── GET /api/raw Posledni TX/RX frame ─────────────────────────────────────
static String hexBuf(const uint8_t* buf, uint8_t len) {
String s;
s.reserve(len * 3);
for (uint8_t i = 0; i < len; i++) {
char h[4];
snprintf(h, sizeof(h), "%02X ", buf[i]);
s += h;
}
if (s.length() > 0) s.remove(s.length() - 1);
return s;
}
static void handleApiRaw() {
String json = "{";
json += "\"tx\":\"" + hexBuf(gMb->lastTx, gMb->lastTxLen) + "\"";
json += ",\"rx\":\"" + hexBuf(gMb->lastRx, gMb->lastRxLen) + "\"";
json += ",\"s\":\"" + String(gMb->statusStr(gMb->lastResult)) + "\"";
json += "}";
gServer->sendHeader("Access-Control-Allow-Origin", "*");
gServer->send(200, "application/json", json);
}
// ─── Inicializace ─────────────────────────────────────────────────────────────
void webServerBegin(SensorData& data, SensorConfig& cfg,
Preferences& prefs, ModbusClient& mb) {
gData = &data;
gCfg = &cfg;
gPrefs = &prefs;
gMb = &mb;
gServer = new WebServer(80);
static const char* hdrs[] = {"Cookie"};
gServer->collectHeaders(hdrs, 1);
gServer->on("/", HTTP_GET, handleRoot);
gServer->on("/detect", HTTP_POST, handleDetect);
gServer->on("/config", HTTP_GET, handleConfig);
gServer->on("/config", HTTP_POST, handleConfigPost);
gServer->on("/action", HTTP_POST, handleAction);
gServer->on("/api/data", HTTP_GET, handleApiData);
gServer->on("/api/status", HTTP_GET, handleApiStatus);
gServer->on("/api/raw", HTTP_GET, handleApiRaw);
gServer->onNotFound([]() {
gServer->send(404, "text/plain", "Not found");
});
gServer->begin();
Serial.println(F("[WEB] HTTP server spusten na portu 80"));
}
void webServerHandle() {
if (gServer) gServer->handleClient();
}

View File

@ -0,0 +1,29 @@
#ifndef WS_SERVER_H
#define WS_SERVER_H
/*
* ws_server.h HTTP webserver
*
* Stranky:
* GET / Stav snimace + ziva data (wind speed + Beaufort)
* GET /config Konfigurace snimace
* POST /config Ulozeni konfigurace
* POST /detect Re-detekce snimace
* POST /action Akce: change_addr
* GET /api/data JSON s aktualnimi daty
* GET /api/status JSON se stavem detekce
* GET /api/raw Posledni RS485 TX/RX
*/
#include <WebServer.h>
#include <Preferences.h>
#include "cfg.h"
#include "mb_client.h"
#include "snr.h"
#include "i18n_str.h"
void webServerBegin(SensorData& data, SensorConfig& cfg,
Preferences& prefs, ModbusClient& mb);
void webServerHandle();
#endif // WS_SERVER_H

View File

@ -1,43 +0,0 @@
#pragma once
#include <Arduino.h>
#include <ETH.h>
#define ETH_PHY_TYPE ETH_PHY_LAN8720
#define ETH_PHY_ADDR 0
#define ETH_PHY_MDC 23
#define ETH_PHY_MDIO 18
#define ETH_PHY_POWER -1
#define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT
static constexpr int PIN_RS485_RX = 36;
static constexpr int PIN_RS485_TX = 4;
static constexpr uint8_t DEFAULT_MODBUS_ADDR = 0x01;
static constexpr uint32_t DEFAULT_MODBUS_BAUD = 4800;
static constexpr uint16_t REG_DATA_START = 500;
static constexpr uint16_t REG_DATA_COUNT = 16;
static constexpr uint16_t REG_CFG_WIND_DIR_OFFSET = 0x6000;
static constexpr uint16_t REG_CFG_RAIN_SENSITIVITY = 0x6003;
// Wind zero command prefix without CRC. CRC is appended automatically in software.
// User-requested raw frame prefix: 01 AB 01 06 01 02 00 5A A9 CD
static constexpr const char* CMD_WIND_ZERO = "01AB01060102005AA9CD";
static constexpr uint16_t REG_CFG_RAIN_ZERO = 0x0104;
static constexpr uint16_t CFG_RAIN_ZERO = 0x005A;
static constexpr unsigned long SENSOR_POLL_MS = 2000;
static constexpr unsigned long DETECT_RECHECK_MS = 30000;
static constexpr unsigned long ETH_LOG_MS = 10000;
static constexpr size_t HTTP_RESERVE_LARGE = 32000;
static constexpr size_t HTTP_RESERVE_MED = 12000;
enum class AirQualityMode : uint8_t {
Auto = 0,
PM = 1,
CO2 = 2
};

View File

@ -1,262 +0,0 @@
#pragma once
#include <Arduino.h>
struct Texts {
const char* appTitle;
const char* tabMain;
const char* tabConfig;
const char* language;
const char* sensorStatus;
const char* values;
const char* present;
const char* absent;
const char* pollAge;
const char* seconds;
const char* windSpeed;
const char* beaufort;
const char* windDir8;
const char* windDirDeg;
const char* humidity;
const char* temperature;
const char* noise;
const char* pressure;
const char* lightHigh;
const char* lightLow;
const char* lightLux100;
const char* rain;
const char* compass;
const char* solarRadiation;
const char* sensorWind;
const char* sensorHumidityTemp;
const char* sensorNoise;
const char* sensorAir;
const char* sensorPressure;
const char* sensorLight;
const char* sensorRain;
const char* sensorCompass;
const char* sensorSolar;
const char* configTitle;
const char* address;
const char* baudrate;
const char* save;
const char* writeOk;
const char* writeFail;
const char* execute;
const char* windOffset;
const char* rainSensitivity;
const char* commandWindZero;
const char* commandRainZero;
const char* tableItem;
const char* tableSetting;
const char* tableNote;
const char* modbusLog;
const char* modbusFrames;
const char* modbusLastStatus;
const char* modbusAction;
const char* modbusDuration;
const char* modbusCounters;
const char* modbusError;
const char* modbusTx;
const char* modbusRx;
const char* noteAddress;
const char* noteBaudrate;
const char* noteWindOffset;
const char* noteRainSensitivity;
const char* noteWindZero;
const char* noteRainZero;
const char* rawRegisters;
const char* manualCommand;
const char* manualAddress;
const char* manualRegisterHex;
const char* manualValueHex;
const char* manualSend;
const char* manualPreview;
const char* manualNote;
const char* rawHexTitle;
const char* rawHexInput;
const char* rawHexSend;
const char* rawHexNote;
const char* noResult;
const char* statusOkShort;
const char* statusErrorShort;
};
enum class UiLang : uint8_t {
CZ = 0,
EN = 1
};
inline UiLang parseLang(const String& v) {
String s = v;
s.toLowerCase();
return (s == "en") ? UiLang::EN : UiLang::CZ;
}
inline const char* langCode(UiLang lang) {
return (lang == UiLang::EN) ? "en" : "cz";
}
inline const Texts& T(UiLang lang) {
static const Texts cz = {
"ESPlan Meteostanice RS-FSXCS-N01",
"Hlavní stránka",
"Konfigurace",
"Jazyk",
"Stav čidel",
"Aktuální hodnoty",
"Přítomno",
"Nepřítomno",
"Stáří dat",
"s",
"Rychlost větru",
"Síla větru",
"Směr větru 0-7",
"Směr větru",
"Vlhkost",
"Teplota",
"Hluk",
"Tlak",
"Osvětlení high",
"Osvětlení low",
"Osvětlení",
"Déšť",
"Kompas",
"Sluneční radiace",
"Vítr",
"Vlhkost / Teplota",
"Hluk",
"Kvalita vzduchu",
"Tlak",
"Světlo",
"Déšť",
"Kompas",
"Solární",
"Konfigurace snímače",
"Adresa (ESP32)",
"Baudrate (ESP32)",
"Uložit",
"Zápis OK",
"Zápis CHYBA",
"Provést",
"Offset směru větru",
"Citlivost optického deště",
"Nulování větru",
"Nulování deště",
"Položka",
"Nastavení",
"Poznámka",
"Modbus log",
"Modbus rámce",
"Stav",
"Akce",
"Doba",
"Počítadla",
"Chyba",
"TX",
"RX",
"Toto nastavení mění pouze komunikaci ESP32, ne konfiguraci samotného snímače.",
"Toto nastavení mění pouze komunikaci ESP32, ne konfiguraci samotného snímače.",
"Registr 0x6000, HEX hodnota.",
"Registr 0x6003, HEX hodnota.",
"Zapíše kalibrační příkaz do snímače.",
"Zapíše nulovací příkaz do snímače.",
"Surové registry",
"Ruční Modbus příkaz",
"Adresa",
"Registr (HEX)",
"Hodnota (HEX)",
"Odeslat",
"Rámec s CRC",
"Odešle se funkce 0x06. Adresa je decimálně, registr a hodnota jsou HEX. CRC se počítá automaticky.",
"Celý HEX příkaz",
"HEX rámec",
"Odeslat HEX",
"Zadej celý rámec v HEX bez CRC(dopočítá se automaticky). Neověřuje se správnost rámce, odesílá se tak jak je zadán.",
"Zatím bez výsledku",
"OK",
"CHYBA"
};
static const Texts en = {
"ESPlan Weather Station RS-FSXCS-N01",
"Main page",
"Configuration",
"Language",
"Sensor status",
"Current values",
"Present",
"Absent",
"Data age",
"s",
"Wind speed",
"Beaufort",
"Wind direction 0-7",
"Wind direction",
"Humidity",
"Temperature",
"Noise",
"Pressure",
"Light high",
"Light low",
"Light",
"Rain",
"Compass",
"Solar radiation",
"Wind",
"Humidity / Temperature",
"Noise",
"Air quality",
"Pressure",
"Light",
"Rain",
"Compass",
"Solar",
"Sensor configuration",
"Address (ESP32)",
"Baudrate (ESP32)",
"Save",
"Write OK",
"Write FAIL",
"Execute",
"Wind direction offset",
"Optical rain sensitivity",
"Zero wind",
"Zero rain",
"Item",
"Setting",
"Note",
"Modbus log",
"Modbus frames",
"Status",
"Action",
"Duration",
"Counters",
"Error",
"TX",
"RX",
"This setting affects only ESP32 communication, not the sensor configuration itself.",
"This setting affects only ESP32 communication, not the sensor configuration itself.",
"Register 0x6000, HEX value.",
"Register 0x6003, HEX value.",
"Writes the calibration command to the sensor.",
"Writes the reset command to the sensor.",
"Raw registers",
"Manual Modbus command",
"Address",
"Register (HEX)",
"Value (HEX)",
"Send",
"Frame with CRC",
"Function 0x06 will be sent. Address is decimal, register and value are HEX. CRC is calculated automatically.",
"Full HEX command",
"HEX frame",
"Send HEX",
"Enter the entire frame in HEX without CRC (it will be calculated automatically). The frame is not verified for correctness, it is sent as entered.",
"No result yet",
"OK",
"ERROR"
};
return (lang == UiLang::EN) ? en : cz;
}

View File

@ -1,531 +0,0 @@
#include "WeatherStation.h"
#include <string.h>
#include <stdlib.h>
static bool parseHexStringLocal(const String& input, uint8_t* out, size_t& outLen, size_t maxLen) {
String s = input;
s.trim();
s.replace(" ", "");
s.replace("\t", "");
s.replace("\r", "");
s.replace("\n", "");
if (s.length() == 0 || (s.length() % 2) != 0) {
outLen = 0;
return false;
}
const size_t bytes = s.length() / 2;
if (bytes > maxLen) {
outLen = 0;
return false;
}
outLen = 0;
for (size_t i = 0; i < bytes; i++) {
const String tok = s.substring(i * 2, i * 2 + 2);
char* endptr = nullptr;
const unsigned long v = strtoul(tok.c_str(), &endptr, 16);
if (*endptr != '\0' || v > 0xFFUL) {
outLen = 0;
return false;
}
out[outLen++] = static_cast<uint8_t>(v);
}
return true;
}
WeatherStation::WeatherStation(HardwareSerial& serial)
: _serial(serial) {}
void WeatherStation::begin() {
_serial.begin(_config.baudrate, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
}
bool WeatherStation::setBaudrate(uint32_t baud) {
_config.baudrate = baud;
_serial.updateBaudRate(baud);
return true;
}
uint16_t WeatherStation::crc16(const uint8_t* data, size_t len) const {
uint16_t crc = 0xFFFF;
for (size_t pos = 0; pos < len; pos++) {
crc ^= static_cast<uint16_t>(data[pos]);
for (uint8_t i = 0; i < 8; i++) {
if (crc & 0x0001) {
crc >>= 1;
crc ^= 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
int16_t WeatherStation::toSigned(uint16_t v) {
return static_cast<int16_t>(v);
}
String WeatherStation::toHex(const uint8_t* data, size_t len) {
static const char* HEX_CHARS = "0123456789ABCDEF";
String out;
out.reserve(len * 3);
for (size_t i = 0; i < len; i++) {
if (i) out += ' ';
out += HEX_CHARS[(data[i] >> 4) & 0x0F];
out += HEX_CHARS[data[i] & 0x0F];
}
return out;
}
const __FlashStringHelper* WeatherStation::exceptionText(uint8_t code) {
switch (code) {
case 0x01: return F("illegal function");
case 0x02: return F("illegal data address");
case 0x03: return F("illegal data value");
case 0x04: return F("slave device failure");
case 0x05: return F("acknowledge");
case 0x06: return F("slave device busy");
case 0x08: return F("memory parity error");
case 0x0A: return F("gateway path unavailable");
case 0x0B: return F("gateway target failed");
default: return F("modbus exception");
}
}
void WeatherStation::setSimpleError(const String& error) {
_commLog.lastOk = false;
_commLog.lastDurationMs = 0;
_commLog.lastAction = String();
_commLog.lastRequestHex = String();
_commLog.lastResponseHex = String();
_commLog.lastError = error;
_commLog.errorCount++;
}
void WeatherStation::setCommResult(
bool ok,
uint32_t durationMs,
const String& action,
const uint8_t* req,
size_t reqLen,
const uint8_t* resp,
size_t respLen,
const String& error) {
_commLog.lastOk = ok;
_commLog.lastDurationMs = durationMs;
_commLog.lastAction = action;
_commLog.lastRequestHex = req ? toHex(req, reqLen) : String();
_commLog.lastResponseHex = resp ? toHex(resp, respLen) : String();
_commLog.lastError = error;
if (ok) {
_commLog.successCount++;
} else {
_commLog.errorCount++;
}
}
bool WeatherStation::transact(
const uint8_t* request,
size_t reqLen,
uint8_t* response,
size_t responseCap,
uint32_t timeoutMs,
const String& action,
size_t* actualLen) {
while (_serial.available()) {
_serial.read();
}
const uint32_t t0 = millis();
_serial.write(request, reqLen);
_serial.flush();
size_t got = 0;
uint32_t lastByteAt = 0;
bool seenAnyByte = false;
while ((millis() - t0) < timeoutMs && got < responseCap) {
bool gotByteNow = false;
while (_serial.available() && got < responseCap) {
response[got++] = static_cast<uint8_t>(_serial.read());
lastByteAt = millis();
seenAnyByte = true;
gotByteNow = true;
}
if (seenAnyByte && !gotByteNow && (millis() - lastByteAt) >= 20) {
break;
}
delay(1);
}
if (actualLen) {
*actualLen = got;
}
if (!seenAnyByte) {
setCommResult(false, millis() - t0, action, request, reqLen, response, 0, F("timeout"));
return false;
}
setCommResult(true, millis() - t0, action, request, reqLen, response, got, String());
return true;
}
bool WeatherStation::sendRawFrame(const uint8_t* request, size_t reqLen, uint8_t* response, size_t responseCapacity, uint32_t timeoutMs, const String& action) {
if (request == nullptr || reqLen == 0) {
setSimpleError(F("invalid args"));
return false;
}
uint8_t frame[260] = {0};
if (reqLen > sizeof(frame)) {
setSimpleError(F("raw frame too long"));
return false;
}
memcpy(frame, request, reqLen);
size_t frameLen = reqLen;
bool hasValidCrc = false;
if (frameLen >= 4) {
const uint16_t crcProvided = static_cast<uint16_t>(frame[frameLen - 2]) | (static_cast<uint16_t>(frame[frameLen - 1]) << 8);
const uint16_t crcCalculated = crc16(frame, frameLen - 2);
hasValidCrc = (crcProvided == crcCalculated);
}
if (!hasValidCrc) {
if ((frameLen + 2) > sizeof(frame)) {
setSimpleError(F("raw frame too long"));
return false;
}
const uint16_t crc = crc16(frame, frameLen);
frame[frameLen++] = static_cast<uint8_t>(crc & 0xFF);
frame[frameLen++] = static_cast<uint8_t>((crc >> 8) & 0xFF);
}
size_t actualLen = 0;
const uint32_t effectiveTimeoutMs = timeoutMs < 2000 ? 2000 : timeoutMs;
return transact(frame, frameLen, response, responseCapacity, effectiveTimeoutMs, action, &actualLen);
}
bool WeatherStation::sendRawFrame(const uint8_t* request, size_t reqLen, uint32_t timeoutMs, const String& action) {
uint8_t resp[128] = {0};
return sendRawFrame(request, reqLen, resp, sizeof(resp), timeoutMs, action);
}
bool WeatherStation::readHoldingRegisters(uint16_t startReg, uint16_t count, uint16_t* out) {
if (count == 0 || out == nullptr) {
setSimpleError(F("invalid args"));
return false;
}
uint8_t req[8];
req[0] = _config.address;
req[1] = 0x03;
req[2] = static_cast<uint8_t>((startReg >> 8) & 0xFF);
req[3] = static_cast<uint8_t>(startReg & 0xFF);
req[4] = static_cast<uint8_t>((count >> 8) & 0xFF);
req[5] = static_cast<uint8_t>(count & 0xFF);
const uint16_t crc = crc16(req, 6);
req[6] = static_cast<uint8_t>(crc & 0xFF);
req[7] = static_cast<uint8_t>((crc >> 8) & 0xFF);
uint8_t resp[64] = {0};
size_t respLen = 0;
char regHex[5];
snprintf(regHex, sizeof(regHex), "%04X", startReg);
String action = F("read 0x");
action += regHex;
action += F(" + ");
action += String(count);
if (!transact(req, sizeof(req), resp, sizeof(resp), 2000, action, &respLen)) {
return false;
}
if (respLen < 5) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("short response"));
return false;
}
if (resp[0] != _config.address) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("wrong slave address"));
return false;
}
const uint16_t crcResp = static_cast<uint16_t>(resp[respLen - 2]) | (static_cast<uint16_t>(resp[respLen - 1]) << 8);
const uint16_t crcCalc = crc16(resp, respLen - 2);
if (crcResp != crcCalc) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("crc mismatch"));
return false;
}
if (resp[1] == static_cast<uint8_t>(0x03 | 0x80)) {
String err = F("modbus exception: ");
err += String(exceptionText(resp[2]));
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, err);
return false;
}
if (resp[1] != 0x03) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("bad function"));
return false;
}
const size_t expectedLen = 5 + static_cast<size_t>(count) * 2;
if (respLen != expectedLen) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("bad length"));
return false;
}
if (resp[2] != count * 2) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("bad byte count"));
return false;
}
for (uint16_t i = 0; i < count; i++) {
out[i] = (static_cast<uint16_t>(resp[3 + i * 2]) << 8) | static_cast<uint16_t>(resp[4 + i * 2]);
}
_commLog.lastOk = true;
_commLog.lastError = String();
return true;
}
bool WeatherStation::writeRegister(uint16_t reg, uint16_t value) {
uint8_t req[8];
req[0] = _config.address;
req[1] = 0x06;
req[2] = static_cast<uint8_t>((reg >> 8) & 0xFF);
req[3] = static_cast<uint8_t>(reg & 0xFF);
req[4] = static_cast<uint8_t>((value >> 8) & 0xFF);
req[5] = static_cast<uint8_t>(value & 0xFF);
const uint16_t crc = crc16(req, 6);
req[6] = static_cast<uint8_t>(crc & 0xFF);
req[7] = static_cast<uint8_t>((crc >> 8) & 0xFF);
uint8_t resp[16] = {0};
size_t respLen = 0;
char regHex[5];
char valHex[5];
snprintf(regHex, sizeof(regHex), "%04X", reg);
snprintf(valHex, sizeof(valHex), "%04X", value);
String action = F("write 0x");
action += regHex;
action += F(" = 0x");
action += valHex;
if (!transact(req, sizeof(req), resp, sizeof(resp), 2000, action, &respLen)) {
return false;
}
if (respLen < 5) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("short response"));
return false;
}
if (resp[0] != _config.address) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("wrong slave address"));
return false;
}
const uint16_t crcResp = static_cast<uint16_t>(resp[respLen - 2]) | (static_cast<uint16_t>(resp[respLen - 1]) << 8);
const uint16_t crcCalc = crc16(resp, respLen - 2);
if (crcResp != crcCalc) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("crc mismatch"));
return false;
}
if (resp[1] == static_cast<uint8_t>(0x06 | 0x80)) {
String err = F("modbus exception: ");
err += String(exceptionText(resp[2]));
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, err);
return false;
}
if (resp[1] != 0x06) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("bad function"));
return false;
}
if (respLen != 8) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("bad length"));
return false;
}
if (memcmp(req, resp, 6) != 0) {
setCommResult(false, _commLog.lastDurationMs, action, req, sizeof(req), resp, respLen, F("write echo mismatch"));
return false;
}
_commLog.lastOk = true;
_commLog.lastError = String();
return true;
}
bool WeatherStation::readAll() {
uint16_t regs[REG_DATA_COUNT] = {0};
if (!readHoldingRegisters(REG_DATA_START, REG_DATA_COUNT, regs)) {
_values.registersValid = false;
return false;
}
for (uint8_t i = 0; i < REG_DATA_COUNT; i++) {
_values.raw[i] = regs[i];
}
_values.registersValid = true;
_values.lastReadMs = millis();
decodeRegisters();
detectPresence();
return true;
}
bool WeatherStation::readSensorConfig() {
uint16_t regs[4] = {0};
if (!readHoldingRegisters(REG_CFG_WIND_DIR_OFFSET, 4, regs)) {
_config.sensorConfigReadOk = false;
return false;
}
_config.windOffset = regs[0];
_config.rainSensitivity = regs[3];
_config.sensorConfigReadOk = true;
return true;
}
void WeatherStation::decodeRegisters() {
_values.windSpeed = _values.raw[0] / 10.0f;
_values.windForce = static_cast<int>(_values.raw[1]);
_values.windDir8 = static_cast<int>(_values.raw[2]);
_values.windDirDeg = static_cast<int>(_values.raw[3]);
_values.humidity = _values.raw[4] / 10.0f;
_values.temperature = toSigned(_values.raw[5]) / 10.0f;
_values.noise = _values.raw[6] / 10.0f;
_values.air1 = static_cast<int>(_values.raw[7]);
_values.air2 = static_cast<int>(_values.raw[8]);
_values.pressureKpa = _values.raw[9] / 10.0f;
_values.lightHigh = _values.raw[10];
_values.lightLow = _values.raw[11];
_values.lightLux = _values.raw[12];
_values.rain = _values.raw[13] / 10.0f;
_values.compassDeg = _values.raw[14] / 100.0f;
_values.solarRadiation = static_cast<int>(_values.raw[15]);
}
void WeatherStation::detectPresence() {
_presence.wind =
(_values.windSpeed >= 0.0f) &&
(_values.windDirDeg >= 0) &&
(_values.windDirDeg <= 359);
_presence.humidityTemp =
isfinite(_values.humidity) &&
isfinite(_values.temperature) &&
(_values.humidity >= 0.0f && _values.humidity <= 100.0f) &&
(_values.temperature > -50.0f && _values.temperature < 90.0f);
if (isfinite(_values.noise) &&
_values.noise >= 29.5f &&
_values.noise <= 30.5f) {
_presence.noise = false;
_values.noise = NAN;
} else {
_presence.noise = isfinite(_values.noise) && (_values.noise > 0.0f) && (_values.noise <= 150.0f);
}
_presence.air = (_values.air1 > 0 || _values.air2 > 0);
if (!_presence.air) {
_values.air1 = -1;
_values.air2 = -1;
}
_presence.pressure = isfinite(_values.pressureKpa) && (_values.pressureKpa > 10.0f);
if (!_presence.pressure) _values.pressureKpa = NAN;
_presence.light = (_values.lightLux > 0) || (_values.lightHigh > 0) || (_values.lightLow > 0);
if (!_presence.light) {
_values.lightLux = 0;
_values.lightHigh = 0;
_values.lightLow = 0;
}
_presence.rain = isfinite(_values.rain) && (_values.rain >= 0.0f);
if (!_presence.rain) _values.rain = NAN;
_presence.compass = isfinite(_values.compassDeg) && (_values.compassDeg > 0.01f);
if (!_presence.compass) _values.compassDeg = NAN;
_presence.solar = (_values.solarRadiation > 0);
if (!_presence.solar) _values.solarRadiation = -1;
}
bool WeatherStation::commandZeroWind() {
uint8_t req[64];
size_t reqLen = 0;
if (!parseHexStringLocal(String(CMD_WIND_ZERO), req, reqLen, sizeof(req))) {
setSimpleError(F("bad wind zero prefix"));
return false;
}
if (reqLen + 2 > sizeof(req)) {
setSimpleError(F("wind zero frame too long"));
return false;
}
const uint16_t crc = crc16(req, reqLen);
req[reqLen++] = static_cast<uint8_t>(crc & 0xFF);
req[reqLen++] = static_cast<uint8_t>((crc >> 8) & 0xFF);
uint8_t resp[32] = {0};
size_t respLen = 0;
if (!transact(req, reqLen, resp, sizeof(resp), 2000, F("wind zero raw"), &respLen)) {
return false;
}
if (respLen == 0) {
setCommResult(false, _commLog.lastDurationMs, F("wind zero raw"), req, reqLen, resp, respLen, F("no response"));
return false;
}
_commLog.lastOk = true;
_commLog.lastError = String();
return true;
}
bool WeatherStation::commandZeroRain() {
return writeRegister(REG_CFG_RAIN_ZERO, CFG_RAIN_ZERO);
}
void WeatherStation::pushConfigLog() {
_lastConfigLog = _commLog;
_hasConfigLog = true;
}
const CommLog& WeatherStation::configLogAt(size_t reverseIndex) const {
static const CommLog empty;
if (!_hasConfigLog || reverseIndex != 0) {
return empty;
}
return _lastConfigLog;
}
String WeatherStation::airLabel1() const {
if (_config.airMode == AirQualityMode::CO2) return F("CO2");
return F("PM2.5");
}
String WeatherStation::airLabel2() const {
if (_config.airMode == AirQualityMode::CO2) return F("CO2");
return F("PM10");
}

View File

@ -1,112 +0,0 @@
#pragma once
#include <Arduino.h>
#include <HardwareSerial.h>
#include "Config.h"
struct VariantPresence {
bool wind = false;
bool humidityTemp = false;
bool noise = false;
bool air = false;
bool pressure = false;
bool light = false;
bool rain = false;
bool compass = false;
bool solar = false;
};
struct WeatherValues {
uint16_t raw[REG_DATA_COUNT] = {0};
bool registersValid = false;
uint32_t lastReadMs = 0;
float windSpeed = NAN;
int windForce = -1;
int windDir8 = -1;
int windDirDeg = -1;
float humidity = NAN;
float temperature = NAN;
float noise = NAN;
int air1 = -1;
int air2 = -1;
float pressureKpa = NAN;
uint16_t lightHigh = 0;
uint16_t lightLow = 0;
uint32_t lightLux = 0;
float rain = NAN;
float compassDeg = NAN;
int solarRadiation = -1;
};
struct RuntimeConfig {
uint8_t address = DEFAULT_MODBUS_ADDR;
uint32_t baudrate = DEFAULT_MODBUS_BAUD;
AirQualityMode airMode = AirQualityMode::Auto;
uint16_t windOffset = 0;
uint16_t rainSensitivity = 0x0011;
bool sensorConfigReadOk = false;
};
struct CommLog {
bool lastOk = false;
uint32_t lastDurationMs = 0;
uint32_t successCount = 0;
uint32_t errorCount = 0;
String lastAction;
String lastRequestHex;
String lastResponseHex;
String lastError;
};
class WeatherStation {
public:
explicit WeatherStation(HardwareSerial& serial);
void begin();
bool setBaudrate(uint32_t baud);
bool readAll();
bool readSensorConfig();
bool writeRegister(uint16_t reg, uint16_t value);
bool commandZeroWind();
bool commandZeroRain();
bool sendRawFrame(const uint8_t* request, size_t reqLen, uint8_t* response, size_t responseCapacity, uint32_t timeoutMs, const String& action);
bool sendRawFrame(const uint8_t* request, size_t reqLen, uint32_t timeoutMs, const String& action);
void pushConfigLog();
size_t configLogCount() const { return _hasConfigLog ? 1 : 0; }
const CommLog& configLogAt(size_t reverseIndex) const;
const WeatherValues& values() const { return _values; }
const VariantPresence& presence() const { return _presence; }
RuntimeConfig& config() { return _config; }
const RuntimeConfig& config() const { return _config; }
const CommLog& commLog() const { return _commLog; }
String airLabel1() const;
String airLabel2() const;
private:
HardwareSerial& _serial;
WeatherValues _values;
VariantPresence _presence;
RuntimeConfig _config;
CommLog _commLog;
CommLog _lastConfigLog;
bool _hasConfigLog = false;
uint16_t crc16(const uint8_t* data, size_t len) const;
bool transact(const uint8_t* request, size_t reqLen, uint8_t* response, size_t responseCap, uint32_t timeoutMs, const String& action, size_t* actualLen = nullptr);
bool readHoldingRegisters(uint16_t startReg, uint16_t count, uint16_t* out);
void decodeRegisters();
void detectPresence();
void setCommResult(bool ok, uint32_t durationMs, const String& action, const uint8_t* req, size_t reqLen, const uint8_t* resp, size_t respLen, const String& error);
void setSimpleError(const String& error);
static int16_t toSigned(uint16_t v);
static String toHex(const uint8_t* data, size_t len);
static const __FlashStringHelper* exceptionText(uint8_t code);
};

View File

@ -1,645 +0,0 @@
#include "WebUi.h"
#include "logo_web.h"
String htmlEscape(const String& in) {
String out;
out.reserve(in.length() + 16);
for (size_t i = 0; i < in.length(); i++) {
switch (in[i]) {
case '&': out += F("&amp;"); break;
case '<': out += F("&lt;"); break;
case '>': out += F("&gt;"); break;
case '"': out += F("&quot;"); break;
case '\'': out += F("&#39;"); break;
default: out += in[i]; break;
}
}
return out;
}
String fmtFloat(float v, uint8_t decimals) {
if (isnan(v) || !isfinite(v)) return F("-");
return String(static_cast<double>(v), static_cast<unsigned int>(decimals));
}
static String jsonFloat(float v, uint8_t decimals = 1) {
if (isnan(v) || !isfinite(v)) return F("null");
return String(static_cast<double>(v), static_cast<unsigned int>(decimals));
}
String jsonString(const String& in) {
String out;
out.reserve(in.length() + 8);
for (size_t i = 0; i < in.length(); i++) {
char c = in[i];
switch (c) {
case '\"': out += F("\\\""); break;
case '\\': out += F("\\\\"); break;
case '\n': out += F("\\n"); break;
case '\r': out += F("\\r"); break;
case '\t': out += F("\\t"); break;
case '\b': out += F("\\b"); break;
case '\f': out += F("\\f"); break;
default:
if ((uint8_t)c < 32) {
out += '?';
} else {
out += c;
}
}
}
return out;
}
static String hex16(uint16_t value) {
char buf[5];
snprintf(buf, sizeof(buf), "%04X", value);
return String(buf);
}
static String statusBadge(bool present, const Texts& t) {
String s;
s.reserve(64);
s += F("<span class='");
s += present ? F("ok'>") : F("bad'>");
s += present ? t.present : t.absent;
s += F("</span>");
return s;
}
String buildStyle(const Texts&, UiLang) {
return F(
"<style>"
"body{font-family:Arial,sans-serif;background:#f4f7fb;color:#222;margin:0;padding:20px;}"
".wrap{max-width:1800px;margin:0 auto;}"
".grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;align-items:stretch;}"
".card{background:#fff;border-radius:14px;padding:16px;box-shadow:0 2px 12px rgba(0,0,0,.08);min-width:0;height:100%;box-sizing:border-box;display:flex;flex-direction:column;}"
".card-wide{grid-column:1 / -1;}"
".top{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;}"
".title{display:flex;align-items:center;gap:18px;flex-wrap:wrap}"
".brand-logo{display:block;max-width:180px;width:100%;height:auto}"
".nav{display:flex;gap:10px;flex-wrap:wrap;margin:10px 0 18px 0}"
".nav a{display:inline-block;padding:10px 14px;border-radius:10px;background:#e8eef8;color:#1f6feb;text-decoration:none;font-weight:bold}"
".nav a.active{background:#1f6feb;color:#fff}"
"table{width:100%;border-collapse:collapse;table-layout:fixed}"
"th,td{padding:8px;border-bottom:1px solid #ddd;text-align:left;font-size:14px;vertical-align:top;word-break:break-word}"
".ok{color:#0a7a2f;font-weight:bold}.bad{color:#b00020;font-weight:bold}"
".sub{color:#637186;font-size:13px}"
".mono{font-family:monospace;white-space:pre-wrap;background:#eef2f7;padding:10px;border-radius:10px;overflow:auto}"
".mono-frames{max-height:140px;font-size:12px;line-height:1.3}"
".mono-raw{max-height:420px;font-size:12px;line-height:1.3}"
".cfg-table td:first-child{width:22%;font-weight:bold}"
".cfg-table td:nth-child(2){width:28%}"
".cfg-table td:nth-child(3){width:50%}"
".cfg-inline-form{margin:0}"
".cfg-inline-form input,.cfg-inline-form select,.cfg-inline-form textarea{margin:0}"
"label{display:block;margin:8px 0 4px;font-weight:bold}"
"input[type=text],input[type=number],select,textarea{width:100%;padding:10px;border:1px solid #ccc;border-radius:8px;box-sizing:border-box}"
"textarea{resize:vertical;min-height:88px;font-family:monospace}"
"button{padding:10px 14px;border:0;border-radius:10px;background:#1f6feb;color:#fff;cursor:pointer;margin-top:8px}"
"button:hover{opacity:.92}"
".row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}"
".msg{padding:10px 12px;border-radius:10px;margin-bottom:12px}"
".note{font-size:13px;color:#5e6a7a}"
"@media (max-width:1400px){.grid{grid-template-columns:repeat(2,1fr);}}"
"@media (max-width:800px){.grid{grid-template-columns:1fr}.row2{grid-template-columns:1fr}.brand-logo{max-width:240px;}}"
"</style>");
}
static void appendTop(String& html, UiLang lang, const Texts& t, const char* active) {
html += F("<div class='top'><div class='title'><img src='/logo.png' alt='logo' class='brand-logo'><div><h1>");
html += t.appTitle;
html += F("</h1></div></div>");
html += F("<form method='GET' action='");
html += (strcmp(active, "main") == 0) ? F("/") : F("/config");
html += F("' style='display:flex;gap:8px;align-items:center;margin:0;'>");
html += F("<label style='margin:0;font-weight:bold;'>");
html += t.language;
html += F("</label><select name='lang' onchange='this.form.submit()'>");
html += F("<option value='cz'");
if (lang == UiLang::CZ) html += F(" selected");
html += F(">CZ</option><option value='en'");
if (lang == UiLang::EN) html += F(" selected");
html += F(">ENG</option></select></form></div>");
html += F("<div class='nav'>");
html += F("<a href='/?lang="); html += langCode(lang); html += F("' class='");
html += (strcmp(active, "main") == 0) ? F("active") : F("");
html += F("'>"); html += t.tabMain; html += F("</a>");
html += F("<a href='/config?lang="); html += langCode(lang); html += F("' class='");
html += (strcmp(active, "config") == 0) ? F("active") : F("");
html += F("'>"); html += t.tabConfig; html += F("</a>");
html += F("</div>");
}
static void appendRow(String& html, const String& id, const String& label, const String& value) {
html += F("<tr><td>");
html += label;
html += F("</td><td id='");
html += id;
html += F("'>");
html += value;
html += F("</td></tr>");
}
static String buildConfigHistoryBox(const Texts& t, const WeatherStation& ws) {
String html;
html.reserve(1800);
html += F("<div class='card card-wide' style='margin-bottom:16px;'><h2>");
html += t.modbusLog;
html += F("</h2>");
if (ws.configLogCount() == 0) {
html += F("<div class='note'>");
html += t.noResult;
html += F("</div></div>");
return html;
}
const CommLog& c = ws.configLogAt(0);
html += F("<table>");
appendRow(html, F("cfg-log-state"), t.modbusLastStatus, c.lastOk ? String(F("<span class='ok'>")) + t.statusOkShort + F("</span>") : String(F("<span class='bad'>")) + t.statusErrorShort + F("</span>"));
appendRow(html, F("cfg-log-action"), t.modbusAction, htmlEscape(c.lastAction));
appendRow(html, F("cfg-log-duration"), t.modbusDuration, String(c.lastDurationMs) + F(" ms"));
appendRow(html, F("cfg-log-error"), t.modbusError, c.lastError.length() ? htmlEscape(c.lastError) : F("-"));
html += F("</table>");
html += F("<div class='sub' style='margin-top:10px'>");
html += t.modbusTx;
html += F("</div><div class='mono'>");
html += htmlEscape(c.lastRequestHex);
html += F("</div>");
html += F("<div class='sub' style='margin-top:10px'>");
html += t.modbusRx;
html += F("</div><div class='mono'>");
html += htmlEscape(c.lastResponseHex);
html += F("</div>");
html += F("</div>");
return html;
}
static void appendMainCards(String& html, const Texts& t, const WeatherStation& ws, bool ethUp, const String& ip, const String& mac) {
(void)ethUp;
(void)ip;
(void)mac;
const auto& p = ws.presence();
const auto& v = ws.values();
const auto& c = ws.commLog();
html += F("<div class='grid'>");
html += F("<div class='card'><h2>");
html += t.sensorStatus;
html += F("</h2><table>");
appendRow(html, F("st-wind"), t.sensorWind, statusBadge(p.wind, t));
appendRow(html, F("st-th"), t.sensorHumidityTemp, statusBadge(p.humidityTemp, t));
appendRow(html, F("st-noise"), t.sensorNoise, statusBadge(p.noise, t));
appendRow(html, F("st-air"), t.sensorAir, statusBadge(p.air, t));
appendRow(html, F("st-pressure"), t.sensorPressure, statusBadge(p.pressure, t));
appendRow(html, F("st-light"), t.sensorLight, statusBadge(p.light, t));
appendRow(html, F("st-rain"), t.sensorRain, statusBadge(p.rain, t));
appendRow(html, F("st-compass"), t.sensorCompass, statusBadge(p.compass, t));
appendRow(html, F("st-solar"), t.sensorSolar, statusBadge(p.solar, t));
html += F("</table></div>");
html += F("<div class='card'><h2>");
html += t.values;
html += F("</h2><table>");
appendRow(html, F("v-wind-short"), t.windSpeed, fmtFloat(v.windSpeed, 1));
appendRow(html, F("v-beaufort-short"), t.beaufort, (v.windForce >= 0 ? String(v.windForce) : F("-")));
appendRow(html, F("v-dir8-short"), t.windDir8, (v.windDir8 >= 0 ? String(v.windDir8) : F("-")));
appendRow(html, F("v-dirdeg-short"), t.windDirDeg, (v.windDirDeg >= 0 ? String(v.windDirDeg) : F("-")));
appendRow(html, F("v-hum-short"), t.humidity, fmtFloat(v.humidity, 1));
appendRow(html, F("v-temp-short"), t.temperature, fmtFloat(v.temperature, 1));
appendRow(html, F("v-noise-short"), t.noise, fmtFloat(v.noise, 1));
appendRow(html, F("v-air1-short"), ws.airLabel1(), (v.air1 >= 0 ? String(v.air1) : F("-")));
appendRow(html, F("v-air2-short"), ws.airLabel2(), (v.air2 >= 0 ? String(v.air2) : F("-")));
appendRow(html, F("v-pressure-short"), t.pressure, fmtFloat(v.pressureKpa, 1));
appendRow(html, F("v-luxh-short"), t.lightHigh, String(v.lightHigh));
appendRow(html, F("v-luxl-short"), t.lightLow, String(v.lightLow));
appendRow(html, F("v-lux-short"), t.lightLux100, String(v.lightLux));
appendRow(html, F("v-rain-short"), t.rain, fmtFloat(v.rain, 1));
appendRow(html, F("v-compass-short"), t.compass, fmtFloat(v.compassDeg, 2));
appendRow(html, F("v-solar-short"), t.solarRadiation, (v.solarRadiation >= 0 ? String(v.solarRadiation) : F("-")));
appendRow(html, F("data-age"), t.pollAge, String((millis() - v.lastReadMs) / 1000UL) + String(F(" ")) + t.seconds);
html += F("</table></div>");
html += F("<div class='card'><h2>");
html += t.modbusLog;
html += F("</h2><table>");
appendRow(html, F("mb-state"), t.modbusLastStatus, c.lastOk ? String(F("<span class='ok'>")) + t.statusOkShort + F("</span>") : String(F("<span class='bad'>")) + t.statusErrorShort + F("</span>"));
appendRow(html, F("mb-action"), t.modbusAction, htmlEscape(c.lastAction));
appendRow(html, F("mb-dur"), t.modbusDuration, String(c.lastDurationMs) + F(" ms"));
appendRow(html, F("mb-stats"), t.modbusCounters, String(c.successCount) + F(" / ") + String(c.errorCount));
appendRow(html, F("mb-err"), t.modbusError, c.lastError.length() ? htmlEscape(c.lastError) : F("-"));
html += F("</table></div>");
html += F("<div class='card'><h2>");
html += t.modbusFrames;
html += F("</h2>");
html += F("<div class='sub'>");
html += t.modbusTx;
html += F("</div><div class='mono mono-frames' id='mb-tx'>");
html += htmlEscape(c.lastRequestHex);
html += F("</div>");
html += F("<div class='sub' style='margin-top:10px'>");
html += t.modbusRx;
html += F("</div><div class='mono mono-frames' id='mb-rx'>");
html += htmlEscape(c.lastResponseHex);
html += F("</div>");
html += F("<div class='sub' style='margin-top:12px'>");
html += t.rawRegisters;
html += F("</div><div class='mono mono-raw' id='raw-regs'>");
for (uint8_t i = 0; i < REG_DATA_COUNT; i++) {
html += F("R");
html += String(REG_DATA_START + i);
html += F(" = ");
html += String(v.raw[i]);
if (i + 1 < REG_DATA_COUNT) html += '\n';
}
html += F("</div></div>");
html += F("</div>");
}
static String buildMainScript(UiLang lang, const Texts& t) {
String s;
s.reserve(9000);
s += F("<script>");
s += F("const L={present:\""); s += t.present;
s += F("\",absent:\""); s += t.absent;
s += F("\",seconds:\""); s += t.seconds;
s += F("\",ok:\""); s += t.statusOkShort;
s += F("\",err:\""); s += t.statusErrorShort;
s += F("\"};");
s += F("function badge(v){return \"<span class='\"+(v?\"ok\":\"bad\")+\"'>\"+(v?L.present:L.absent)+\"</span>\";}");
s += F("function setHtml(id,v){const e=document.getElementById(id);if(e)e.innerHTML=v;}");
s += F("function setText(id,v){const e=document.getElementById(id);if(e)e.textContent=v;}");
s += F("function num(v,d){if(v===null||v===undefined||v==='-'||Number.isNaN(Number(v)))return '-';return Number(v).toFixed(d);}");
s += F("async function tick(){");
s += F("try{");
s += F("const url='/data.json?lang="); s += langCode(lang); s += F("&_=' + Date.now();");
s += F("const r=await fetch(url,{cache:'no-store'});");
s += F("if(!r.ok)return;");
s += F("const d=await r.json();");
s += F("setHtml('st-wind',badge(d.pwind));");
s += F("setHtml('st-th',badge(d.pth));");
s += F("setHtml('st-noise',badge(d.pnoise));");
s += F("setHtml('st-air',badge(d.pair));");
s += F("setHtml('st-pressure',badge(d.ppress));");
s += F("setHtml('st-light',badge(d.plight));");
s += F("setHtml('st-rain',badge(d.prain));");
s += F("setHtml('st-compass',badge(d.pcomp));");
s += F("setHtml('st-solar',badge(d.psolar));");
s += F("setText('v-wind-short',num(d.wind,1));");
s += F("setText('v-beaufort-short',(d.beaufort<0)?'-':String(d.beaufort));");
s += F("setText('v-dir8-short',(d.dir8<0)?'-':String(d.dir8));");
s += F("setText('v-dirdeg-short',(d.dirdeg<0)?'-':String(d.dirdeg));");
s += F("setText('v-hum-short',num(d.hum,1));");
s += F("setText('v-temp-short',num(d.temp,1));");
s += F("setText('v-noise-short',num(d.noise,1));");
s += F("setText('v-air1-short',(d.air1<0?'-':String(d.air1)));");
s += F("setText('v-air2-short',(d.air2<0?'-':String(d.air2)));");
s += F("setText('v-pressure-short',num(d.press,1));");
s += F("setText('v-luxh-short',String(d.lightHigh));");
s += F("setText('v-luxl-short',String(d.lightLow));");
s += F("setText('v-lux-short',String(d.light));");
s += F("setText('v-rain-short',num(d.rain,1));");
s += F("setText('v-compass-short',num(d.compass,2));");
s += F("setText('v-solar-short',(d.solar<0)?'-':String(d.solar));");
s += F("setText('data-age',String(d.age)+' '+L.seconds);");
s += F("setHtml('mb-state',d.mbOk?\"<span class='ok'>\"+L.ok+\"</span>\":\"<span class='bad'>\"+L.err+\"</span>\");");
s += F("setText('mb-action',d.mbAction||'-');");
s += F("setText('mb-dur',String(d.mbDuration)+' ms');");
s += F("setText('mb-stats',String(d.mbOkCount)+' / '+String(d.mbErrCount));");
s += F("setText('mb-err',d.mbError||'-');");
s += F("setText('mb-tx',d.mbTx||'');");
s += F("setText('mb-rx',d.mbRx||'');");
s += F("if(Array.isArray(d.raw)){setText('raw-regs',d.raw.map((v,i)=>'R'+(500+i)+' = '+v).join('\\n'));}");
s += F("}catch(e){}");
s += F("}");
s += F("window.addEventListener('load',function(){tick();setInterval(tick,2000);});");
s += F("</script>");
return s;
}
static String buildManualCommandScript() {
String s;
s.reserve(2400);
s += F("<script>");
s += F("function wsCrc16(bytes){let crc=0xFFFF;for(let i=0;i<bytes.length;i++){crc^=bytes[i];for(let j=0;j<8;j++){if(crc&1){crc=(crc>>1)^0xA001;}else{crc>>=1;}}}return crc&0xFFFF;}");
s += F("function wsHexByte(v){return Number(v).toString(16).toUpperCase().padStart(2,'0');}");
s += F("function wsHexWordFromInput(id,defv){let s=(document.getElementById(id).value||defv).trim();s=s.replace(/^0x/i,'');let v=parseInt(s,16);if(Number.isNaN(v))v=parseInt(defv,16);if(Number.isNaN(v))v=0;return Math.max(0,Math.min(65535,v));}");
s += F("function wsUpdateManualPreview(){");
s += F("const a=Math.max(1,Math.min(247,parseInt(document.getElementById('manual-addr').value||'1',10)));");
s += F("const r=wsHexWordFromInput('manual-reg','6000');");
s += F("const v=wsHexWordFromInput('manual-val','0000');");
s += F("const frame=[a,0x06,(r>>8)&0xFF,r&0xFF,(v>>8)&0xFF,v&0xFF];");
s += F("const crc=wsCrc16(frame);frame.push(crc&0xFF);frame.push((crc>>8)&0xFF);");
s += F("const el=document.getElementById('manual-preview');if(el){el.textContent=frame.map(wsHexByte).join(' ');}}");
s += F("window.addEventListener('load',function(){['manual-addr','manual-reg','manual-val'].forEach(function(id){const e=document.getElementById(id);if(e){e.addEventListener('input',wsUpdateManualPreview);e.addEventListener('change',wsUpdateManualPreview);}});wsUpdateManualPreview();});");
s += F("</script>");
return s;
}
String buildMainPage(UiLang lang, const WeatherStation& ws, bool ethUp, const String& ip, const String& mac) {
const auto& t = T(lang);
String html;
html.reserve(24000);
html += F("<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>");
html += t.appTitle;
html += F("</title>");
html += buildStyle(t, lang);
html += F("</head><body><div class='wrap'>");
appendTop(html, lang, t, "main");
appendMainCards(html, t, ws, ethUp, ip, mac);
html += buildMainScript(lang, t);
html += F("</div></body></html>");
return html;
}
String buildConfigPage(UiLang lang, const WeatherStation& ws, const String& flashMsg) {
const auto& t = T(lang);
String html;
html.reserve(24000);
(void)flashMsg;
html += F("<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>");
html += t.tabConfig;
html += F("</title>");
html += buildStyle(t, lang);
html += F("</head><body><div class='wrap'>");
appendTop(html, lang, t, "config");
html += F("<div class='grid'>");
html += buildConfigHistoryBox(t, ws);
html += F("<div class='card card-wide'><h2>");
html += t.manualCommand;
html += F("</h2><div class='note'>");
html += t.manualNote;
html += F("</div>");
html += F("<form method='POST' action='/cmd-manual?lang=");
html += langCode(lang);
html += F("'>");
html += F("<div class='row2'>");
html += F("<div><label>");
html += t.manualAddress;
html += F("</label><input type='number' id='manual-addr' name='address' min='1' max='247' value='");
html += String(ws.config().address);
html += F("'></div>");
html += F("<div><label>");
html += t.manualRegisterHex;
html += F("</label><input type='text' id='manual-reg' name='reg' value='6000' placeholder='6000'></div>");
html += F("<div><label>");
html += t.manualValueHex;
html += F("</label><input type='text' id='manual-val' name='value' value='0000' placeholder='0000'></div>");
html += F("<div><label>");
html += t.manualPreview;
html += F("</label><div class='mono' id='manual-preview'>-</div></div>");
html += F("</div><button type='submit'>");
html += t.manualSend;
html += F("</button></form>");
html += F("<hr style='margin:16px 0;border:none;border-top:1px solid #ddd'>");
html += F("<h2>");
html += t.rawHexTitle;
html += F("</h2><div class='note'>");
html += t.rawHexNote;
html += F("</div>");
html += F("<form method='POST' action='/cmd-rawhex?lang=");
html += langCode(lang);
html += F("'>");
html += F("<label>");
html += t.rawHexInput;
html += F("</label><textarea name='rawhex' placeholder='010660000001560A'></textarea>");
html += F("<button type='submit'>");
html += t.rawHexSend;
html += F("</button></form></div>");
html += F("<div class='card card-wide'><h2>");
html += t.configTitle;
html += F("</h2>");
html += F("<table class='cfg-table'>");
html += F("<tr><th>");
html += t.tableItem;
html += F("</th><th>");
html += t.tableSetting;
html += F("</th><th>");
html += t.tableNote;
html += F("</th></tr>");
html += F("<tr><td>");
html += t.address;
html += F("</td><td>");
html += F("<form class='cfg-inline-form' method='POST' action='/config-save?lang=");
html += langCode(lang);
html += F("'>");
html += F("<input type='hidden' name='baudrate' value='");
html += String(ws.config().baudrate);
html += F("'>");
html += F("<input type='hidden' name='windOffset' value='");
html += hex16(ws.config().windOffset);
html += F("'>");
html += F("<input type='hidden' name='rainSensitivity' value='");
html += hex16(ws.config().rainSensitivity);
html += F("'>");
html += F("<input type='number' name='address' min='1' max='247' value='");
html += String(ws.config().address);
html += F("'> <button type='submit'>");
html += t.save;
html += F("</button></form></td><td>");
html += t.noteAddress;
html += F("</td></tr>");
html += F("<tr><td>");
html += t.baudrate;
html += F("</td><td>");
html += F("<form class='cfg-inline-form' method='POST' action='/config-save?lang=");
html += langCode(lang);
html += F("'>");
html += F("<input type='hidden' name='address' value='");
html += String(ws.config().address);
html += F("'>");
html += F("<input type='hidden' name='windOffset' value='");
html += hex16(ws.config().windOffset);
html += F("'>");
html += F("<input type='hidden' name='rainSensitivity' value='");
html += hex16(ws.config().rainSensitivity);
html += F("'>");
html += F("<select name='baudrate'>");
const uint32_t baudrates[] = {1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200};
for (uint8_t i = 0; i < sizeof(baudrates) / sizeof(baudrates[0]); i++) {
html += F("<option value='");
html += String(baudrates[i]);
html += F("'");
if (baudrates[i] == ws.config().baudrate) html += F(" selected");
html += F(">");
html += String(baudrates[i]);
html += F("</option>");
}
html += F("</select> <button type='submit'>");
html += t.save;
html += F("</button></form></td><td>");
html += t.noteBaudrate;
html += F("</td></tr>");
html += F("<tr><td>");
html += t.windOffset;
html += F("</td><td>");
html += F("<form class='cfg-inline-form' method='POST' action='/config-save?lang=");
html += langCode(lang);
html += F("'>");
html += F("<input type='hidden' name='address' value='");
html += String(ws.config().address);
html += F("'>");
html += F("<input type='hidden' name='baudrate' value='");
html += String(ws.config().baudrate);
html += F("'>");
html += F("<input type='hidden' name='rainSensitivity' value='");
html += hex16(ws.config().rainSensitivity);
html += F("'>");
html += F("<input type='text' name='windOffset' value='");
html += hex16(ws.config().windOffset);
html += F("' placeholder='0000'> <button type='submit'>");
html += t.save;
html += F("</button></form></td><td>");
html += t.noteWindOffset;
html += F("</td></tr>");
html += F("<tr><td>");
html += t.rainSensitivity;
html += F("</td><td>");
html += F("<form class='cfg-inline-form' method='POST' action='/config-save?lang=");
html += langCode(lang);
html += F("'>");
html += F("<input type='hidden' name='address' value='");
html += String(ws.config().address);
html += F("'>");
html += F("<input type='hidden' name='baudrate' value='");
html += String(ws.config().baudrate);
html += F("'>");
html += F("<input type='hidden' name='windOffset' value='");
html += hex16(ws.config().windOffset);
html += F("'>");
html += F("<input type='text' name='rainSensitivity' value='");
html += hex16(ws.config().rainSensitivity);
html += F("' placeholder='0011'> <button type='submit'>");
html += t.save;
html += F("</button></form></td><td>");
html += t.noteRainSensitivity;
html += F("</td></tr>");
html += F("<tr><td>");
html += t.commandWindZero;
html += F("</td><td><form class='cfg-inline-form' method='POST' action='/cmd-wind-zero?lang=");
html += langCode(lang);
html += F("'><button type='submit'>");
html += t.execute;
html += F("</button></form></td><td>");
html += t.noteWindZero;
html += F("</td></tr>");
html += F("<tr><td>");
html += t.commandRainZero;
html += F("</td><td><form class='cfg-inline-form' method='POST' action='/cmd-rain-zero?lang=");
html += langCode(lang);
html += F("'><button type='submit'>");
html += t.execute;
html += F("</button></form></td><td>");
html += t.noteRainZero;
html += F("</td></tr>");
html += F("</table></div>");
html += F("</div>");
html += buildManualCommandScript();
html += F("</div></body></html>");
return html;
}
String buildMainDataJson(const WeatherStation& ws, bool ethUp, const String& ip, const String& mac) {
const auto& v = ws.values();
const auto& p = ws.presence();
const auto& c = ws.commLog();
String json;
json.reserve(2400);
json += F("{");
json += F("\"eth\":"); json += ethUp ? F("true") : F("false");
json += F(",\"ip\":\""); json += jsonString(ip); json += F("\"");
json += F(",\"mac\":\""); json += jsonString(mac); json += F("\"");
json += F(",\"age\":"); json += String((millis() - v.lastReadMs) / 1000UL);
json += F(",\"wind\":"); json += jsonFloat(v.windSpeed);
json += F(",\"beaufort\":"); json += (v.windForce >= 0 ? String(v.windForce) : F("-1"));
json += F(",\"dir8\":"); json += (v.windDir8 >= 0 ? String(v.windDir8) : F("-1"));
json += F(",\"dirdeg\":"); json += (v.windDirDeg >= 0 ? String(v.windDirDeg) : F("-1"));
json += F(",\"temp\":"); json += jsonFloat(v.temperature);
json += F(",\"hum\":"); json += jsonFloat(v.humidity);
json += F(",\"noise\":"); json += jsonFloat(v.noise);
json += F(",\"air1\":"); json += String(v.air1);
json += F(",\"air2\":"); json += String(v.air2);
json += F(",\"press\":"); json += jsonFloat(v.pressureKpa);
json += F(",\"rain\":"); json += jsonFloat(v.rain);
json += F(",\"solar\":"); json += String(v.solarRadiation);
json += F(",\"light\":"); json += String(v.lightLux);
json += F(",\"lightHigh\":"); json += String(v.lightHigh);
json += F(",\"lightLow\":"); json += String(v.lightLow);
json += F(",\"compass\":"); json += jsonFloat(v.compassDeg, 2);
json += F(",\"pwind\":"); json += p.wind ? F("true") : F("false");
json += F(",\"pth\":"); json += p.humidityTemp ? F("true") : F("false");
json += F(",\"pnoise\":"); json += p.noise ? F("true") : F("false");
json += F(",\"pair\":"); json += p.air ? F("true") : F("false");
json += F(",\"ppress\":"); json += p.pressure ? F("true") : F("false");
json += F(",\"plight\":"); json += p.light ? F("true") : F("false");
json += F(",\"prain\":"); json += p.rain ? F("true") : F("false");
json += F(",\"pcomp\":"); json += p.compass ? F("true") : F("false");
json += F(",\"psolar\":"); json += p.solar ? F("true") : F("false");
json += F(",\"mbOk\":"); json += c.lastOk ? F("true") : F("false");
json += F(",\"mbDuration\":"); json += String(c.lastDurationMs);
json += F(",\"mbOkCount\":"); json += String(c.successCount);
json += F(",\"mbErrCount\":"); json += String(c.errorCount);
json += F(",\"mbAction\":\""); json += jsonString(c.lastAction); json += F("\"");
json += F(",\"mbError\":\""); json += jsonString(c.lastError); json += F("\"");
json += F(",\"mbTx\":\""); json += jsonString(c.lastRequestHex); json += F("\"");
json += F(",\"mbRx\":\""); json += jsonString(c.lastResponseHex); json += F("\"");
json += F(",\"raw\":[");
for (uint8_t i = 0; i < REG_DATA_COUNT; i++) {
if (i) json += ',';
json += String(v.raw[i]);
}
json += F("]}");
return json;
}

View File

@ -1,13 +0,0 @@
#pragma once
#include <Arduino.h>
#include <WebServer.h>
#include "Lang.h"
#include "WeatherStation.h"
String htmlEscape(const String& in);
String fmtFloat(float v, uint8_t decimals = 1);
String buildMainPage(UiLang lang, const WeatherStation& ws, bool ethUp, const String& ip, const String& mac);
String buildConfigPage(UiLang lang, const WeatherStation& ws, const String& flashMsg);
String buildMainDataJson(const WeatherStation& ws, bool ethUp, const String& ip, const String& mac);
String buildStyle(const Texts& t, UiLang lang);

View File

@ -1,455 +0,0 @@
#include <Arduino.h>
#include <WiFi.h>
#include <ETH.h>
#include <Preferences.h>
#include <WebServer.h>
#include <stdlib.h>
#include "Config.h"
#include "Lang.h"
#include "WeatherStation.h"
#include "WebUi.h"
#include "logo_web.h"
Preferences prefs;
WebServer server(80);
HardwareSerial RS485Serial(2);
WeatherStation station(RS485Serial);
bool ethConnected = false;
bool ethGotIp = false;
unsigned long lastPollMs = 0;
unsigned long lastDetectRecheckMs = 0;
bool uiOnConfigPage = false;
String flashMessage;
static void fwLog(const String& msg) {
Serial.println(String(F("[FW] ")) + msg);
}
static void sendNoCacheHeaders() {
server.sendHeader(F("Cache-Control"), F("no-store, no-cache, must-revalidate, max-age=0"));
server.sendHeader(F("Pragma"), F("no-cache"));
server.sendHeader(F("Expires"), F("0"));
}
static void loadPreferences() {
prefs.begin("meteo", true);
station.config().address = prefs.getUChar("addr", DEFAULT_MODBUS_ADDR);
station.config().baudrate = prefs.getUInt("baud", DEFAULT_MODBUS_BAUD);
station.config().airMode = static_cast<AirQualityMode>(prefs.getUChar("airMode", 0));
prefs.end();
}
static void savePreferences() {
prefs.begin("meteo", false);
prefs.putUChar("addr", station.config().address);
prefs.putUInt("baud", station.config().baudrate);
prefs.putUChar("airMode", static_cast<uint8_t>(station.config().airMode));
prefs.end();
}
void WiFiEvent(WiFiEvent_t event) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
ETH.setHostname("esplan-weather");
break;
case ARDUINO_EVENT_ETH_CONNECTED:
ethConnected = true;
fwLog(F("ETH link connected"));
break;
case ARDUINO_EVENT_ETH_GOT_IP:
ethConnected = true;
ethGotIp = true;
fwLog(String(F("ETH IP acquired: ")) + ETH.localIP().toString());
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
ethConnected = false;
ethGotIp = false;
fwLog(F("ETH link disconnected"));
break;
case ARDUINO_EVENT_ETH_STOP:
ethConnected = false;
ethGotIp = false;
fwLog(F("ETH stopped"));
break;
default:
break;
}
}
static UiLang currentLang() {
if (server.hasArg("lang")) return parseLang(server.arg("lang"));
return UiLang::CZ;
}
static String currentIp() {
return ethGotIp ? ETH.localIP().toString() : String("0.0.0.0");
}
static String currentMac() {
return ETH.macAddress();
}
static void redirectWithMessage(const String& url, UiLang lang, const String& msg) {
flashMessage = msg;
const String target = url + (url.indexOf('?') >= 0 ? '&' : '?') + String(F("lang=")) + langCode(lang);
server.sendHeader("Location", target, true);
server.send(302, "text/plain", "");
}
static String buildSimpleMessage(UiLang lang, const String& title, const String& detail, bool ok) {
const auto& t = T(lang);
String msg;
msg.reserve(320);
msg += F("<div><strong>");
msg += htmlEscape(title);
msg += F(":</strong> ");
msg += ok ? String(F("<span class='ok'>")) + t.statusOkShort + F("</span>") : String(F("<span class='bad'>")) + t.statusErrorShort + F("</span>");
if (detail.length()) {
msg += F("<br>");
msg += htmlEscape(detail);
}
msg += F("</div>");
return msg;
}
static String buildConfigResultMessage(UiLang lang, const WeatherStation& ws, const String& title) {
const auto& t = T(lang);
const auto& c = ws.commLog();
String msg;
msg.reserve(900);
msg += F("<div><strong>");
msg += htmlEscape(title);
msg += F(":</strong> ");
msg += c.lastOk ? String(F("<span class='ok'>")) + t.statusOkShort + F("</span>") : String(F("<span class='bad'>")) + t.statusErrorShort + F("</span>");
msg += F("<br>");
msg += htmlEscape(t.modbusAction);
msg += F(": ");
msg += htmlEscape(c.lastAction.length() ? c.lastAction : String("-"));
msg += F("<br>");
msg += htmlEscape(t.modbusDuration);
msg += F(": ");
msg += String(c.lastDurationMs);
msg += F(" ms");
msg += F("<br>");
msg += htmlEscape(t.modbusError);
msg += F(": ");
msg += (c.lastError.length() ? htmlEscape(c.lastError) : F("-"));
msg += F("</div>");
return msg;
}
static bool parseHex16(const String& s, uint16_t& out) {
String v = s;
v.trim();
v.replace("0x", "");
v.replace("0X", "");
if (v.length() == 0 || v.length() > 4) return false;
char* endptr = nullptr;
unsigned long n = strtoul(v.c_str(), &endptr, 16);
if (*endptr != '\0' || n > 0xFFFFUL) return false;
out = static_cast<uint16_t>(n);
return true;
}
static bool parseUint32Strict(const String& s, uint32_t& out) {
String v = s;
v.trim();
if (v.length() == 0) return false;
char* endptr = nullptr;
unsigned long n = strtoul(v.c_str(), &endptr, 10);
if (*endptr != '\0') return false;
out = static_cast<uint32_t>(n);
return true;
}
static bool parseHexBytesLoose(const String& input, uint8_t* out, size_t& outLen, size_t maxLen) {
String s = input;
s.trim();
s.replace(" ", "");
s.replace("\t", "");
s.replace("\r", "");
s.replace("\n", "");
if (s.length() == 0 || (s.length() % 2) != 0) {
outLen = 0;
return false;
}
size_t bytes = s.length() / 2;
if (bytes > maxLen) {
outLen = 0;
return false;
}
outLen = 0;
for (size_t i = 0; i < bytes; i++) {
String tok = s.substring(i * 2, i * 2 + 2);
char* endptr = nullptr;
unsigned long v = strtoul(tok.c_str(), &endptr, 16);
if (*endptr != '\0' || v > 0xFFUL) {
outLen = 0;
return false;
}
out[outLen++] = static_cast<uint8_t>(v);
}
return true;
}
static bool writeRegisterToAddress(uint8_t slaveAddr, uint16_t reg, uint16_t value) {
const uint8_t oldAddr = station.config().address;
station.config().address = slaveAddr;
const bool ok = station.writeRegister(reg, value);
station.config().address = oldAddr;
return ok;
}
static void handleRoot() {
uiOnConfigPage = false;
UiLang lang = currentLang();
sendNoCacheHeaders();
server.send(200, "text/html; charset=utf-8", buildMainPage(lang, station, ethConnected && ethGotIp, currentIp(), currentMac()));
}
static void handleConfig() {
uiOnConfigPage = true;
UiLang lang = currentLang();
const String msg = flashMessage;
flashMessage = String();
sendNoCacheHeaders();
server.send(200, "text/html; charset=utf-8", buildConfigPage(lang, station, msg));
}
static void handleLogo() {
server.send_P(200, "image/png", reinterpret_cast<PGM_P>(logo_web_png), logo_web_png_len);
}
static void handleJson() {
sendNoCacheHeaders();
server.send(200, "application/json; charset=utf-8", buildMainDataJson(station, ethConnected && ethGotIp, currentIp(), currentMac()));
}
static bool applyRuntimeComm(UiLang lang, String& errorText, bool& changed) {
changed = false;
if (server.hasArg("address")) {
uint32_t addr = 0;
if (!parseUint32Strict(server.arg("address"), addr) || addr < 1 || addr > 247) {
errorText = T(lang).address;
return false;
}
if (station.config().address != static_cast<uint8_t>(addr)) {
station.config().address = static_cast<uint8_t>(addr);
changed = true;
}
}
if (server.hasArg("baudrate")) {
uint32_t baud = 0;
if (!parseUint32Strict(server.arg("baudrate"), baud)) {
errorText = T(lang).baudrate;
return false;
}
switch (baud) {
case 1200:
case 2400:
case 4800:
case 9600:
case 19200:
case 38400:
case 57600:
case 115200:
if (station.config().baudrate != baud) {
station.setBaudrate(baud);
changed = true;
}
break;
default:
errorText = T(lang).baudrate;
return false;
}
}
if (changed) savePreferences();
return true;
}
static void handleConfigSave() {
UiLang lang = currentLang();
const auto& t = T(lang);
String runtimeError;
bool runtimeChanged = false;
if (!applyRuntimeComm(lang, runtimeError, runtimeChanged)) {
redirectWithMessage("/config", lang, buildSimpleMessage(lang, t.save, runtimeError, false));
return;
}
bool didWrite = false;
bool ok = true;
String firstValidationError;
const bool hasWindOffset = server.hasArg("windOffset") && server.arg("windOffset").length() > 0;
const bool hasRainSensitivity = server.hasArg("rainSensitivity") && server.arg("rainSensitivity").length() > 0;
if (hasWindOffset) {
uint16_t v = 0;
if (!parseHex16(server.arg("windOffset"), v)) {
ok = false;
firstValidationError = t.windOffset;
} else {
ok = station.writeRegister(REG_CFG_WIND_DIR_OFFSET, v) && ok;
station.pushConfigLog();
didWrite = true;
if (ok) station.config().windOffset = v;
}
}
if (hasRainSensitivity) {
uint16_t v = 0;
if (!parseHex16(server.arg("rainSensitivity"), v)) {
ok = false;
if (!firstValidationError.length()) firstValidationError = t.rainSensitivity;
} else {
ok = station.writeRegister(REG_CFG_RAIN_SENSITIVITY, v) && ok;
station.pushConfigLog();
didWrite = true;
if (ok) station.config().rainSensitivity = v;
}
}
if (!didWrite) {
const String detail = runtimeChanged ? String() : firstValidationError;
redirectWithMessage("/config", lang, buildSimpleMessage(lang, t.save, detail, runtimeChanged || ok));
return;
}
if (!ok && firstValidationError.length()) {
String msg = buildConfigResultMessage(lang, station, t.save);
msg += F("<div style='margin-top:8px'>");
msg += htmlEscape(firstValidationError);
msg += F("</div>");
redirectWithMessage("/config", lang, msg);
return;
}
redirectWithMessage("/config", lang, buildConfigResultMessage(lang, station, t.save));
}
static void handleCmdWindZero() {
UiLang lang = currentLang();
station.commandZeroWind();
station.pushConfigLog();
redirectWithMessage("/config", lang, buildConfigResultMessage(lang, station, T(lang).commandWindZero));
}
static void handleCmdRainZero() {
UiLang lang = currentLang();
station.commandZeroRain();
station.pushConfigLog();
redirectWithMessage("/config", lang, buildConfigResultMessage(lang, station, T(lang).commandRainZero));
}
static void handleCmdManual() {
UiLang lang = currentLang();
const auto& t = T(lang);
if (!server.hasArg("address") || !server.hasArg("reg") || !server.hasArg("value")) {
redirectWithMessage("/config", lang, buildSimpleMessage(lang, t.manualCommand, t.writeFail, false));
return;
}
uint32_t addrParsed = 0;
uint16_t regIn = 0;
uint16_t valIn = 0;
if (!parseUint32Strict(server.arg("address"), addrParsed) || addrParsed < 1 || addrParsed > 247 ||
!parseHex16(server.arg("reg"), regIn) || !parseHex16(server.arg("value"), valIn)) {
redirectWithMessage("/config", lang, buildSimpleMessage(lang, t.manualCommand, t.writeFail, false));
return;
}
writeRegisterToAddress(static_cast<uint8_t>(addrParsed), regIn, valIn);
station.pushConfigLog();
redirectWithMessage("/config", lang, buildConfigResultMessage(lang, station, t.manualCommand));
}
static void handleCmdRawHex() {
UiLang lang = currentLang();
const auto& t = T(lang);
if (!server.hasArg("rawhex")) {
redirectWithMessage("/config", lang, buildSimpleMessage(lang, t.rawHexTitle, t.writeFail, false));
return;
}
uint8_t frame[128];
size_t frameLen = 0;
if (!parseHexBytesLoose(server.arg("rawhex"), frame, frameLen, sizeof(frame))) {
redirectWithMessage("/config", lang, buildSimpleMessage(lang, t.rawHexTitle, t.writeFail, false));
return;
}
uint8_t resp[128] = {0};
station.sendRawFrame(frame, frameLen, resp, sizeof(resp), 300, String(t.rawHexTitle));
station.pushConfigLog();
redirectWithMessage("/config", lang, buildConfigResultMessage(lang, station, t.rawHexTitle));
}
static void handleNotFound() {
sendNoCacheHeaders();
server.send(404, "text/plain; charset=utf-8", "404 Not Found");
}
static void beginEthernet() {
WiFi.onEvent(WiFiEvent);
ETH.begin(ETH_PHY_TYPE, ETH_PHY_ADDR, ETH_PHY_MDC, ETH_PHY_MDIO, ETH_PHY_POWER, ETH_CLK_MODE);
}
static void beginWeb() {
server.on("/", HTTP_GET, handleRoot);
server.on("/config", HTTP_GET, handleConfig);
server.on("/data.json", HTTP_GET, handleJson);
server.on("/logo.png", HTTP_GET, handleLogo);
server.on("/config-save", HTTP_POST, handleConfigSave);
server.on("/cmd-wind-zero", HTTP_POST, handleCmdWindZero);
server.on("/cmd-rain-zero", HTTP_POST, handleCmdRainZero);
server.on("/cmd-manual", HTTP_POST, handleCmdManual);
server.on("/cmd-rawhex", HTTP_POST, handleCmdRawHex);
server.onNotFound(handleNotFound);
server.begin();
}
void setup() {
Serial.begin(115200);
delay(300);
Serial.println();
Serial.println("ESPlan Weather Station start");
fwLog(F("Boot"));
loadPreferences();
station.begin();
station.readSensorConfig();
station.readAll();
beginEthernet();
beginWeb();
fwLog(F("HTTP server started"));
}
void loop() {
server.handleClient();
const unsigned long now = millis();
if (!uiOnConfigPage && (now - lastPollMs >= SENSOR_POLL_MS)) {
lastPollMs = now;
const bool ok = station.readAll();
if (!ok) {
fwLog(String(F("Modbus read failed: ")) + station.commLog().lastError);
}
}
if (!uiOnConfigPage && (now - lastDetectRecheckMs >= DETECT_RECHECK_MS)) {
lastDetectRecheckMs = now;
const bool ok = station.readSensorConfig();
if (!ok) {
fwLog(String(F("Sensor config refresh failed: ")) + station.commLog().lastError);
}
}
}

View File

@ -1,936 +0,0 @@
#include "logo_web.h"
#include <pgmspace.h>
#include <stddef.h>
const uint8_t logo_web_png[] PROGMEM = {
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x01, 0x90, 0x00, 0x00, 0x00, 0x66,
0x08, 0x06, 0x00, 0x00, 0x00, 0xE5, 0x03, 0xC7, 0x7C, 0x00, 0x00, 0x00,
0x01, 0x73, 0x52, 0x47, 0x42, 0x01, 0xD9, 0xC9, 0x2C, 0x7F, 0x00, 0x00,
0x00, 0x04, 0x67, 0x41, 0x4D, 0x41, 0x00, 0x00, 0xB1, 0x8F, 0x0B, 0xFC,
0x61, 0x05, 0x00, 0x00, 0x00, 0x20, 0x63, 0x48, 0x52, 0x4D, 0x00, 0x00,
0x7A, 0x26, 0x00, 0x00, 0x80, 0x84, 0x00, 0x00, 0xFA, 0x00, 0x00, 0x00,
0x80, 0xE8, 0x00, 0x00, 0x75, 0x30, 0x00, 0x00, 0xEA, 0x60, 0x00, 0x00,
0x3A, 0x98, 0x00, 0x00, 0x17, 0x70, 0x9C, 0xBA, 0x51, 0x3C, 0x00, 0x00,
0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x2C, 0x4B, 0x00, 0x00,
0x2C, 0x4B, 0x01, 0xA5, 0x3D, 0x96, 0xA9, 0x00, 0x00, 0x20, 0x00, 0x49,
0x44, 0x41, 0x54, 0x78, 0xDA, 0xED, 0x9D, 0x79, 0x98, 0x14, 0xC5, 0xF9,
0xC7, 0x3F, 0x35, 0xBB, 0x0B, 0x28, 0x82, 0x88, 0x12, 0x4F, 0xBC, 0xEF,
0xA8, 0xA8, 0x98, 0xA8, 0xC9, 0xA0, 0x02, 0x2A, 0x06, 0xD4, 0x78, 0xD1,
0x6A, 0x34, 0xDE, 0x6D, 0xBC, 0x30, 0xC6, 0xC4, 0xC4, 0xF1, 0xF8, 0x99,
0x78, 0x0D, 0x78, 0xDF, 0x1A, 0x6B, 0xA3, 0x46, 0x45, 0x4D, 0x71, 0x28,
0x06, 0x24, 0x9A, 0xC4, 0x73, 0x8C, 0x07, 0x2A, 0x5E, 0xA8, 0xC8, 0x65,
0xF0, 0x8A, 0x72, 0xDF, 0xC7, 0xEE, 0x4E, 0xFD, 0xFE, 0xA8, 0x1A, 0x76,
0x58, 0x66, 0x76, 0xBB, 0x7B, 0x66, 0x76, 0x66, 0x77, 0xEB, 0xFB, 0x3C,
0x3C, 0xC0, 0x6E, 0x77, 0x75, 0x75, 0x55, 0xF5, 0xFB, 0xAD, 0xF7, 0xAD,
0xF7, 0x10, 0x94, 0x18, 0x42, 0xAA, 0xAE, 0x1A, 0x76, 0x02, 0x76, 0x04,
0x7A, 0x01, 0xEB, 0x03, 0x31, 0xFB, 0xEB, 0xE5, 0xC0, 0x7C, 0x60, 0x96,
0x80, 0xCF, 0xB5, 0xEF, 0xCD, 0xA7, 0x1D, 0x42, 0x48, 0xD5, 0x43, 0xC3,
0xCE, 0xC0, 0xF6, 0xC0, 0xC6, 0x76, 0x0C, 0x00, 0x34, 0xB0, 0x0C, 0x98,
0x0B, 0xCC, 0xB0, 0x63, 0xB0, 0x14, 0x07, 0x07, 0x07, 0x87, 0xB6, 0x20,
0xDB, 0x4A, 0x20, 0x2C, 0x37, 0xD0, 0x70, 0x18, 0x30, 0x08, 0xE8, 0x07,
0xEC, 0x02, 0x54, 0x05, 0xB8, 0x35, 0x0D, 0x7C, 0x09, 0xBC, 0x0E, 0xBC,
0x00, 0x4C, 0xC4, 0xF7, 0xBE, 0x6F, 0xA3, 0x84, 0xB1, 0x89, 0x86, 0x23,
0x80, 0xC3, 0x81, 0x38, 0xB0, 0x4D, 0x16, 0x69, 0x36, 0x87, 0x06, 0x60,
0x1A, 0x90, 0x02, 0xFE, 0x21, 0xE0, 0x05, 0xED, 0x7B, 0x4B, 0xDC, 0x32,
0x75, 0x70, 0x70, 0x68, 0xB7, 0x04, 0xD2, 0xB3, 0x76, 0x94, 0x98, 0xAF,
0x75, 0x1C, 0xF8, 0x15, 0x70, 0x0C, 0xD0, 0xB5, 0x08, 0xCD, 0xD6, 0x03,
0xFF, 0x02, 0x6A, 0xAB, 0x60, 0x5C, 0x83, 0xEF, 0xD5, 0x57, 0xF2, 0x40,
0x56, 0x4B, 0x55, 0x55, 0x0F, 0x47, 0x02, 0x3E, 0x86, 0x40, 0x3B, 0x15,
0xA1, 0xD9, 0xE5, 0xC0, 0xB3, 0xC0, 0x83, 0xDD, 0x85, 0x78, 0x79, 0xF1,
0x39, 0x43, 0xB5, 0x5B, 0xB2, 0x0E, 0x0E, 0x0E, 0x19, 0xE8, 0x64, 0x7C,
0xB0, 0x95, 0xBB, 0xB9, 0xF0, 0x85, 0x48, 0xA4, 0x7E, 0x5D, 0xD9, 0x04,
0x22, 0xD5, 0x60, 0xE0, 0x1A, 0xE0, 0xC7, 0x25, 0xEC, 0xE7, 0x2C, 0x60,
0x44, 0x15, 0x3C, 0xD4, 0xE0, 0x7B, 0x75, 0x15, 0x48, 0x1C, 0xA7, 0x03,
0x09, 0x8C, 0x99, 0xAE, 0x54, 0x98, 0x0C, 0x5C, 0xDB, 0x4D, 0x88, 0x71,
0x4B, 0x1C, 0x91, 0x38, 0x38, 0x38, 0x18, 0x02, 0xB9, 0x00, 0xB8, 0x37,
0xCF, 0xAF, 0x3F, 0x12, 0x89, 0xD4, 0x5E, 0xA5, 0x7C, 0x7E, 0x2C, 0xF2,
0x9D, 0x52, 0xED, 0x8E, 0x54, 0xFF, 0x06, 0x26, 0x94, 0x98, 0x3C, 0x00,
0xB6, 0x03, 0x1E, 0x68, 0x80, 0x0F, 0x91, 0x6A, 0x50, 0xC5, 0xCC, 0x9E,
0x54, 0xFD, 0xEB, 0xE1, 0x7D, 0xE0, 0x2F, 0x25, 0x26, 0x0F, 0x80, 0x7D,
0x80, 0xA7, 0x97, 0x68, 0xFD, 0x0A, 0x52, 0xF5, 0x71, 0x9F, 0x8E, 0x83,
0x83, 0x43, 0xD9, 0x37, 0xD0, 0x61, 0x6F, 0xA8, 0x91, 0xAA, 0xAA, 0x0E,
0xFE, 0x00, 0xFC, 0x1F, 0xD0, 0xB9, 0x95, 0xFB, 0xBB, 0x2B, 0xF0, 0x0F,
0xA4, 0x7A, 0x44, 0xC0, 0xAF, 0xB5, 0xEF, 0x2D, 0x2E, 0xC7, 0xA0, 0xD9,
0x73, 0x9E, 0x5B, 0x31, 0xE6, 0x2A, 0xD1, 0xCA, 0x8F, 0xEF, 0x07, 0x4C,
0x42, 0xAA, 0x1B, 0xAB, 0xE0, 0xFA, 0x4A, 0x37, 0xED, 0x39, 0x38, 0x38,
0xB4, 0x5F, 0x84, 0xD2, 0x40, 0x84, 0x54, 0xBD, 0xEA, 0xE0, 0x79, 0xE0,
0x86, 0x32, 0x90, 0x47, 0x36, 0xCE, 0xD0, 0xF0, 0x2E, 0x52, 0xED, 0xD5,
0xEA, 0x4F, 0x96, 0x6A, 0x37, 0x0D, 0x6F, 0x03, 0xE7, 0x96, 0x81, 0x3C,
0xD6, 0xF0, 0x38, 0x70, 0x4D, 0x03, 0xBC, 0x88, 0x54, 0x9B, 0xB9, 0x65,
0xEC, 0xE0, 0xE0, 0x50, 0x96, 0xCD, 0x74, 0x08, 0xC1, 0xB9, 0x2B, 0xC6,
0x5C, 0xB5, 0x7D, 0x05, 0xF5, 0x7F, 0x29, 0x70, 0x22, 0xBE, 0xF7, 0x5C,
0x2B, 0x91, 0xC7, 0xA1, 0xC0, 0x68, 0x60, 0xC3, 0x0A, 0x1A, 0x83, 0x2F,
0x81, 0x21, 0xF8, 0xDE, 0x47, 0x6E, 0x39, 0x3B, 0x38, 0xB4, 0x5F, 0xE8,
0x64, 0xFC, 0xA7, 0x40, 0xF7, 0x26, 0x3F, 0x3E, 0x12, 0xB8, 0x20, 0xCF,
0x2D, 0x5F, 0xE4, 0xF9, 0xDD, 0x1B, 0x22, 0x91, 0x5A, 0xD8, 0x7A, 0x04,
0x62, 0x6C, 0xEE, 0xFF, 0xC4, 0xC4, 0x71, 0x54, 0x1A, 0xEA, 0x80, 0xD3,
0xF1, 0xBD, 0x27, 0x4B, 0x4C, 0x1E, 0xC7, 0x01, 0x4F, 0x94, 0x59, 0xF3,
0xCA, 0x87, 0xF9, 0xC0, 0x60, 0x7C, 0xEF, 0x2D, 0xF7, 0x99, 0x39, 0x38,
0xB4, 0x5B, 0x02, 0x99, 0x0C, 0xEC, 0x5D, 0x84, 0xA6, 0x0E, 0x14, 0x89,
0xD4, 0x9B, 0xC5, 0xE8, 0x53, 0xCB, 0x26, 0x2C, 0xA9, 0x76, 0xAF, 0x60,
0xF2, 0x00, 0x63, 0xCE, 0x79, 0x14, 0xA9, 0x4E, 0x2A, 0x21, 0x79, 0x1C,
0x03, 0x3C, 0x55, 0xA1, 0xE4, 0x01, 0xD0, 0x13, 0x73, 0x36, 0xB4, 0xB7,
0xFB, 0xCC, 0x1C, 0x1C, 0x1C, 0x5A, 0x0B, 0xB1, 0x16, 0x04, 0xE7, 0x66,
0xC0, 0xC4, 0x0A, 0x26, 0x8F, 0x0C, 0xAA, 0x81, 0xC7, 0x4A, 0x42, 0x22,
0x52, 0x1D, 0x69, 0xC9, 0xA3, 0xA6, 0xC2, 0xC7, 0xA0, 0x07, 0xF0, 0x1C,
0x52, 0xF5, 0x76, 0xCB, 0xDA, 0xC1, 0xC1, 0xA1, 0xB5, 0x04, 0x6F, 0x6E,
0x66, 0x91, 0xAA, 0x53, 0xDA, 0xD8, 0xFB, 0xB7, 0x2E, 0xC2, 0x73, 0xA6,
0x03, 0xEF, 0x00, 0x9F, 0x02, 0x73, 0x80, 0xD5, 0x98, 0xE8, 0xF4, 0x8D,
0x80, 0x1D, 0x80, 0xBE, 0xC0, 0x5E, 0x44, 0xF0, 0x0A, 0xCB, 0x41, 0x22,
0x1A, 0xDF, 0xFB, 0x5B, 0x91, 0xC8, 0x63, 0x08, 0x66, 0x0C, 0x0A, 0xD5,
0x3C, 0xD2, 0xC0, 0x47, 0x76, 0x0C, 0xA6, 0x63, 0x4C, 0x4E, 0x0D, 0x96,
0x94, 0x7A, 0x61, 0xBC, 0xCB, 0xF6, 0xC3, 0xA4, 0x3B, 0x29, 0x04, 0x9B,
0x03, 0x63, 0x63, 0x52, 0xF5, 0x4B, 0xFB, 0xDE, 0x4A, 0xB7, 0xBC, 0x73,
0xCE, 0xE9, 0xCD, 0xC0, 0xA6, 0x45, 0x68, 0xE9, 0x0F, 0xF8, 0xDE, 0xB7,
0x6E, 0x40, 0x1D, 0x1C, 0x81, 0xE4, 0x96, 0x78, 0x7F, 0x02, 0x7E, 0x5A,
0x40, 0xDB, 0xB3, 0x00, 0x09, 0x28, 0x7C, 0x6F, 0x46, 0x4B, 0x17, 0x0B,
0xA9, 0x36, 0xD6, 0x70, 0x34, 0x70, 0x0E, 0xF0, 0x93, 0x02, 0xDE, 0xE7,
0x71, 0x4B, 0x22, 0xAA, 0x08, 0xE4, 0x31, 0xA6, 0x40, 0xF2, 0x78, 0x1B,
0xA8, 0x15, 0x30, 0x4E, 0x07, 0x49, 0xCB, 0x22, 0xD5, 0xB6, 0x80, 0x87,
0x71, 0x0F, 0x8E, 0x1A, 0x57, 0xB2, 0x5F, 0x1A, 0x86, 0x03, 0x97, 0xB8,
0xE5, 0x9D, 0x13, 0x3F, 0xC7, 0xE4, 0x66, 0x2B, 0x14, 0x37, 0x02, 0x8E,
0x40, 0x1C, 0x3A, 0x34, 0x44, 0x1E, 0x41, 0xB6, 0x3F, 0x26, 0x27, 0x55,
0x55, 0x84, 0x36, 0xBF, 0x02, 0xAE, 0xAE, 0x82, 0xC7, 0x23, 0xC7, 0x28,
0x48, 0xD5, 0x0F, 0xB8, 0x19, 0xD8, 0x3F, 0xE2, 0x7B, 0xD5, 0x03, 0xA7,
0x44, 0x26, 0x11, 0x23, 0xC8, 0x3F, 0x05, 0xBA, 0x44, 0x7C, 0xFE, 0x7B,
0xC0, 0x65, 0x9B, 0x08, 0xF1, 0xD2, 0xDC, 0x08, 0x51, 0xE3, 0x36, 0xD6,
0xC6, 0x03, 0x92, 0x98, 0x3C, 0x5A, 0x51, 0x34, 0x9E, 0x01, 0xF8, 0xDE,
0x2B, 0x6E, 0x89, 0xAF, 0x33, 0xB7, 0x9F, 0x17, 0x89, 0x40, 0x76, 0xC3,
0xF7, 0x3E, 0x73, 0x03, 0xEA, 0xD0, 0x5A, 0xA8, 0xC4, 0x43, 0xF4, 0x75,
0x08, 0xC4, 0xA6, 0xE6, 0x98, 0x84, 0x89, 0x7C, 0x0E, 0x8B, 0x5A, 0x01,
0x97, 0x16, 0x23, 0x01, 0x60, 0x27, 0xA9, 0x62, 0xAB, 0xE1, 0x42, 0xE0,
0xA6, 0x88, 0x82, 0xBC, 0xCE, 0x92, 0xC8, 0xA8, 0x88, 0x82, 0xE6, 0xB7,
0x96, 0xC4, 0xC2, 0xC4, 0x7A, 0xAC, 0x06, 0xAE, 0xAC, 0x86, 0xDB, 0xEB,
0x7D, 0xAF, 0xA1, 0xE0, 0xC9, 0x31, 0x99, 0x8C, 0x47, 0x60, 0x5C, 0xF1,
0xC2, 0xC6, 0x9C, 0x7C, 0x1A, 0x83, 0x3E, 0xE9, 0x0A, 0x4A, 0xFD, 0xA2,
0x93, 0xF1, 0x0D, 0x03, 0xBE, 0x47, 0x5A, 0x24, 0x52, 0xA5, 0x09, 0x12,
0x6D, 0xC7, 0x04, 0xA2, 0x93, 0xF1, 0xEE, 0x04, 0x8B, 0xED, 0xD2, 0x22,
0x91, 0x5A, 0xD4, 0xA1, 0x77, 0xCE, 0x52, 0xF5, 0x08, 0xBA, 0x19, 0x2B,
0x57, 0xC0, 0x72, 0x8E, 0xF9, 0x7D, 0x03, 0x63, 0xEA, 0x6F, 0x6A, 0x75,
0xE9, 0xD4, 0xCC, 0x46, 0x32, 0x97, 0x29, 0xBB, 0xBF, 0x48, 0xA4, 0xDE,
0x2E, 0x46, 0x9F, 0xAA, 0x73, 0x6C, 0xDD, 0x4F, 0x8B, 0x40, 0x1E, 0xAB,
0x81, 0xF3, 0xF0, 0xBD, 0x87, 0x8B, 0x95, 0xA4, 0x69, 0xB5, 0xEF, 0xA5,
0x81, 0xBB, 0x91, 0xEA, 0x0D, 0x4C, 0x42, 0xC1, 0xCD, 0xC3, 0x6E, 0xE4,
0x81, 0x27, 0x90, 0x8A, 0x48, 0x24, 0xE2, 0x7B, 0xB7, 0x22, 0x95, 0xB0,
0x24, 0x12, 0x04, 0xDF, 0x03, 0xC7, 0xE1, 0x7B, 0xAF, 0x17, 0x2B, 0x34,
0x5C, 0xFB, 0xDE, 0x32, 0xE0, 0x22, 0xA4, 0xFA, 0x0F, 0xF0, 0x10, 0xE1,
0xCC, 0x69, 0xBB, 0xA5, 0x8D, 0x29, 0xEC, 0xBE, 0x0A, 0xFA, 0x6E, 0xBF,
0x01, 0xD6, 0x0B, 0x70, 0xDD, 0x4C, 0x4A, 0x9F, 0x1A, 0xA6, 0x3D, 0xE2,
0x63, 0x60, 0xAB, 0x00, 0xD7, 0x2D, 0xD1, 0xC3, 0xFB, 0x6D, 0x24, 0x2E,
0x7F, 0x2D, 0xDD, 0x61, 0x77, 0xF3, 0xE6, 0x1C, 0x92, 0xB6, 0xB5, 0x16,
0xC5, 0x4F, 0xD7, 0xDD, 0x80, 0xE9, 0xF3, 0x81, 0xBB, 0xF3, 0xDC, 0x30,
0x05, 0xC4, 0xBA, 0xB2, 0x5C, 0x54, 0x15, 0x6D, 0xDE, 0xD7, 0x22, 0x90,
0x2A, 0xA9, 0xAA, 0x1B, 0xE0, 0xEA, 0x08, 0xE4, 0x71, 0x3C, 0xBE, 0x37,
0xBE, 0x24, 0x63, 0xE6, 0x7B, 0xEF, 0x58, 0x93, 0xD6, 0x4B, 0x40, 0xEF,
0x08, 0xEF, 0x37, 0xD2, 0x9E, 0x89, 0x8C, 0x8E, 0xF0, 0xEC, 0x5B, 0x90,
0x8A, 0x00, 0x24, 0xF2, 0x35, 0x30, 0x10, 0xDF, 0x9B, 0x5A, 0xA2, 0x31,
0x78, 0x02, 0xA9, 0xE6, 0x00, 0xE3, 0x02, 0x0A, 0xE0, 0x0C, 0xAE, 0x8C,
0x49, 0xF5, 0x97, 0xB4, 0xEF, 0xAD, 0xAA, 0x90, 0xEF, 0x36, 0x16, 0x50,
0x03, 0x89, 0xE1, 0x10, 0xD5, 0xA2, 0xE0, 0xC6, 0x37, 0xA2, 0xF5, 0xA5,
0xC0, 0xEB, 0x4A, 0xDF, 0xE1, 0xC4, 0xBA, 0x84, 0xAF, 0x93, 0xF1, 0x74,
0x0B, 0xF7, 0x34, 0x94, 0xFA, 0x83, 0x5E, 0x83, 0x06, 0x38, 0x01, 0x93,
0xB8, 0x30, 0x04, 0x91, 0x73, 0x66, 0xC9, 0xC8, 0xA3, 0x51, 0x80, 0xCE,
0x00, 0xFA, 0x63, 0xA2, 0xAE, 0xC3, 0x22, 0xA3, 0x89, 0x9C, 0x10, 0xF1,
0xD9, 0xB7, 0x00, 0xBF, 0x6F, 0x61, 0x57, 0x3D, 0xA0, 0x64, 0xE4, 0xD1,
0xD8, 0x8F, 0x7F, 0x02, 0xA7, 0x5A, 0xB5, 0x34, 0x28, 0xB6, 0x48, 0x9B,
0x7B, 0x1C, 0x1C, 0x1C, 0x1C, 0x8A, 0x8E, 0xA6, 0x26, 0xAC, 0xF3, 0x43,
0xDE, 0x7F, 0x3B, 0xBE, 0xF7, 0x44, 0xAB, 0xF4, 0xD4, 0xF7, 0x66, 0x20,
0xD5, 0x00, 0xE0, 0xC5, 0x08, 0x9A, 0x48, 0x86, 0x44, 0x34, 0xBE, 0x37,
0x26, 0xC2, 0xB3, 0x6F, 0xB6, 0xE6, 0xAC, 0x11, 0x79, 0xC8, 0xE3, 0xF3,
0x56, 0x1A, 0x83, 0xB1, 0x48, 0x75, 0x23, 0x70, 0x55, 0x88, 0xBB, 0xCE,
0xC3, 0x64, 0x0B, 0x76, 0x30, 0x78, 0x94, 0xE2, 0xC4, 0x35, 0x2D, 0x70,
0x43, 0xE9, 0xE0, 0x08, 0x24, 0x03, 0xA9, 0xB6, 0xC3, 0x64, 0x7A, 0x0D,
0x8A, 0x4F, 0x63, 0x70, 0x65, 0xAB, 0x1A, 0x51, 0x7D, 0x6F, 0xBA, 0x25,
0x91, 0x97, 0x08, 0x66, 0xEB, 0x6D, 0x4A, 0x22, 0x4F, 0x22, 0xD5, 0xC9,
0x11, 0x49, 0xE4, 0x26, 0x6B, 0xCE, 0x1A, 0x6E, 0xD5, 0xDA, 0xD6, 0xD1,
0x3C, 0x9A, 0xA0, 0x0A, 0xAE, 0x6D, 0x80, 0xC1, 0xC0, 0xBE, 0x01, 0x6F,
0xE9, 0x8B, 0x54, 0xBB, 0xE3, 0x7B, 0x9F, 0xB8, 0xE5, 0x0E, 0xF8, 0xDE,
0xF5, 0x6E, 0x10, 0x1C, 0x1C, 0x8A, 0xAF, 0x81, 0x9C, 0x40, 0x38, 0x7B,
0xDF, 0x6F, 0xCA, 0x12, 0xAC, 0x66, 0x48, 0xA4, 0x7F, 0x81, 0x24, 0x72,
0x12, 0xBE, 0x37, 0x36, 0x22, 0x89, 0x08, 0xE0, 0xE2, 0x72, 0x90, 0x07,
0x40, 0x83, 0xEF, 0xD5, 0x21, 0xD5, 0x25, 0xC0, 0x2B, 0x01, 0xE7, 0x4B,
0x00, 0xC7, 0x03, 0x8E, 0x40, 0x1C, 0x1C, 0xDA, 0x1F, 0x5E, 0x02, 0xCE,
0x0E, 0xA4, 0x25, 0x4B, 0x75, 0x16, 0x30, 0x20, 0x80, 0xC0, 0xB8, 0x54,
0x07, 0x2C, 0x27, 0x9E, 0x4D, 0x20, 0x47, 0x84, 0xE8, 0xF4, 0x1B, 0xF8,
0xDE, 0xF3, 0x65, 0xDC, 0x45, 0x4E, 0xCF, 0x32, 0x67, 0x15, 0xA2, 0x89,
0x44, 0x21, 0x91, 0x11, 0x42, 0x2A, 0xA9, 0x7D, 0x6F, 0x7E, 0x19, 0xDF,
0xFF, 0x35, 0x5B, 0xCC, 0xEB, 0xD0, 0x80, 0x77, 0x0C, 0x06, 0xAE, 0x73,
0xDF, 0x9A, 0x83, 0x43, 0xFB, 0x82, 0x48, 0xA4, 0x3E, 0xC5, 0xC4, 0xAC,
0x05, 0xC1, 0xFE, 0xC0, 0x29, 0x2D, 0x5D, 0xA4, 0x4D, 0xAD, 0xA7, 0x40,
0x04, 0x12, 0x03, 0x88, 0x49, 0x55, 0x03, 0x1C, 0x10, 0xA2, 0xDF, 0x77,
0x57, 0x80, 0x29, 0x62, 0x9A, 0x65, 0xD3, 0xAF, 0x23, 0xDC, 0xDD, 0x09,
0x78, 0x0A, 0xA9, 0x8E, 0x8D, 0xF2, 0xE8, 0xB2, 0x92, 0x47, 0xB4, 0x39,
0xE8, 0x2B, 0xA4, 0xEA, 0xEA, 0x3E, 0x37, 0x07, 0x07, 0x87, 0x62, 0x22,
0x06, 0x90, 0x36, 0xC1, 0x29, 0xEB, 0x07, 0xBC, 0x67, 0xA9, 0x80, 0x67,
0x2A, 0xA2, 0xF7, 0x86, 0x44, 0xFA, 0x47, 0x24, 0x91, 0x9A, 0x42, 0x48,
0xA4, 0xDC, 0xA8, 0x32, 0x49, 0x2E, 0x83, 0x12, 0x59, 0x8D, 0x06, 0x57,
0x06, 0xD7, 0xC1, 0xC1, 0xA1, 0xF8, 0x04, 0x82, 0x49, 0xE6, 0x17, 0x14,
0xAF, 0x68, 0xDF, 0x5B, 0x51, 0x31, 0x6F, 0x50, 0x1C, 0x4D, 0xE4, 0x98,
0xB6, 0x36, 0x71, 0x0D, 0x26, 0xC2, 0xFC, 0x9F, 0x21, 0x6E, 0xD9, 0xC3,
0x2D, 0x77, 0x07, 0x07, 0x87, 0x52, 0x10, 0x48, 0x98, 0xD4, 0x0E, 0xFF,
0xA9, 0xB8, 0xB7, 0x30, 0x6E, 0xB4, 0x03, 0x30, 0x9E, 0x51, 0x51, 0x48,
0xE4, 0x6F, 0x6D, 0x91, 0x44, 0x80, 0x30, 0x05, 0xA4, 0x76, 0x72, 0xCB,
0xDD, 0xC1, 0xC1, 0xA1, 0x98, 0xC8, 0x1C, 0xA2, 0x6F, 0x11, 0xE2, 0x9E,
0x29, 0x15, 0xF9, 0x26, 0xBE, 0xF7, 0x79, 0xD6, 0xC1, 0xFA, 0x16, 0x21,
0xEF, 0xCE, 0x90, 0x88, 0x87, 0xEF, 0x8D, 0x6B, 0x43, 0xF3, 0xF7, 0x61,
0x88, 0x6B, 0x7F, 0xD0, 0xD1, 0x16, 0xB7, 0x4E, 0xC6, 0xBB, 0x61, 0x62,
0x3E, 0x7A, 0x60, 0xD2, 0xC0, 0xD4, 0x03, 0xAB, 0x80, 0x79, 0xC0, 0x5C,
0x91, 0x48, 0xAD, 0x2A, 0x43, 0x9F, 0xBA, 0xDB, 0x3E, 0x6D, 0x88, 0xC9,
0xF1, 0x56, 0x87, 0xC9, 0x57, 0x34, 0x1F, 0x98, 0x27, 0x12, 0xA9, 0x0E,
0x9B, 0x86, 0x5F, 0x27, 0xE3, 0x5D, 0x80, 0x4D, 0x80, 0x8D, 0xED, 0xD8,
0xC4, 0xEC, 0xD8, 0x2C, 0xB0, 0xF3, 0xB5, 0xD4, 0x89, 0xEC, 0xCA, 0x24,
0x90, 0xEE, 0x21, 0xEE, 0x99, 0x5D, 0xB1, 0x6F, 0xE3, 0x7B, 0x53, 0x0B,
0x24, 0x11, 0xD5, 0xC6, 0x48, 0x24, 0x4C, 0x3A, 0xF1, 0xEE, 0xED, 0x79,
0x21, 0xEB, 0x64, 0xBC, 0x07, 0x70, 0xB0, 0xFD, 0xB3, 0x37, 0xB0, 0x27,
0xA6, 0x52, 0x63, 0xBE, 0xB4, 0x1D, 0x75, 0x3A, 0x19, 0x9F, 0x01, 0xBC,
0x8B, 0x31, 0x05, 0x4E, 0x10, 0x89, 0xD4, 0xDC, 0xA2, 0xF6, 0x69, 0xC4,
0x21, 0x82, 0x74, 0xFD, 0xBE, 0xC0, 0x10, 0x4C, 0x89, 0x82, 0x7D, 0xAD,
0x70, 0xCC, 0xD7, 0xA7, 0x7A, 0x9D, 0x8C, 0xFF, 0x0F, 0x53, 0x3B, 0xE6,
0x03, 0xE0, 0x35, 0xE0, 0xD5, 0x72, 0x0A, 0x4E, 0x9D, 0x8C, 0x1F, 0x82,
0xA9, 0xD7, 0x13, 0x06, 0xE3, 0x44, 0x22, 0x35, 0xBD, 0xF9, 0x76, 0xFB,
0xD5, 0x80, 0x3E, 0x10, 0xC8, 0xB4, 0xDF, 0x07, 0xD8, 0x92, 0xFC, 0x25,
0x26, 0x1A, 0x74, 0x32, 0xFE, 0x9D, 0x9D, 0xAF, 0x57, 0x81, 0xBF, 0x8B,
0x44, 0x6A, 0x6A, 0x45, 0x2D, 0x42, 0x53, 0x7C, 0x6E, 0x97, 0x90, 0x77,
0x8D, 0xC4, 0xF7, 0xFE, 0xD7, 0xA4, 0x9D, 0xCD, 0x08, 0xE0, 0x2D, 0x65,
0xF1, 0x09, 0xBE, 0x37, 0x31, 0x4F, 0x7F, 0x9A, 0xB6, 0xB3, 0x57, 0xC0,
0x36, 0xCF, 0x41, 0xAA, 0x79, 0xB9, 0x7E, 0x21, 0xE0, 0xB1, 0x6C, 0x17,
0xDF, 0xEA, 0x26, 0x7F, 0x07, 0x41, 0x65, 0xEF, 0x02, 0x8A, 0x43, 0x22,
0x43, 0xF1, 0xBD, 0x67, 0xDB, 0x80, 0xDC, 0x0C, 0x73, 0x16, 0xD5, 0xEE,
0xBC, 0xB0, 0x74, 0x32, 0xDE, 0x19, 0x38, 0x16, 0x93, 0x00, 0x74, 0x20,
0xF9, 0xB3, 0x92, 0xE6, 0x42, 0x0D, 0xE6, 0xEC, 0x6F, 0x57, 0xFB, 0x91,
0xAD, 0xD2, 0xC9, 0xF8, 0x68, 0xE0, 0x7A, 0x91, 0x48, 0x7D, 0x56, 0x60,
0xBF, 0x6A, 0x80, 0x33, 0x49, 0xD7, 0xFF, 0x1A, 0xD8, 0x3D, 0xE4, 0x86,
0x6E, 0x2B, 0xFB, 0xE7, 0x67, 0xC0, 0xE5, 0xC0, 0x0A, 0x9D, 0x8C, 0x3F,
0x0F, 0x3C, 0x0C, 0x62, 0x42, 0xA9, 0x73, 0x1B, 0x35, 0x79, 0x8F, 0x13,
0x80, 0x91, 0x21, 0xC7, 0xF5, 0x21, 0x44, 0xEC, 0xF6, 0x66, 0xDA, 0x8C,
0x03, 0x67, 0x81, 0x3E, 0x06, 0x53, 0x50, 0x2E, 0x28, 0xAA, 0xEC, 0xF7,
0xBC, 0x05, 0x70, 0x14, 0x70, 0x93, 0x4E, 0xC6, 0x53, 0xC0, 0x8D, 0x22,
0x91, 0xFA, 0x47, 0x05, 0x90, 0xC7, 0x05, 0x18, 0xCF, 0xC8, 0x30, 0x79,
0xC6, 0x6E, 0x58, 0x87, 0x3C, 0x0C, 0x7A, 0x03, 0xB7, 0x04, 0x6C, 0xE3,
0x31, 0x8C, 0x43, 0x4D, 0x2E, 0x6C, 0x1D, 0xA2, 0x9D, 0x6C, 0x24, 0xF2,
0xCE, 0x9F, 0x91, 0xAB, 0x6B, 0x08, 0x24, 0x4A, 0x52, 0xB5, 0xCA, 0x4F,
0xC4, 0x66, 0x02, 0xFC, 0x06, 0x12, 0xAD, 0xE0, 0x4F, 0x86, 0x44, 0x8E,
0x6E, 0x03, 0x32, 0x34, 0x4C, 0xBD, 0x96, 0x06, 0xDA, 0x09, 0x74, 0x32,
0x5E, 0xA3, 0x93, 0xF1, 0x8B, 0x30, 0xD5, 0x1D, 0x9F, 0xB4, 0xC2, 0xB6,
0x53, 0x81, 0xCD, 0x76, 0xB6, 0x44, 0xF2, 0xA1, 0x4E, 0xC6, 0xAF, 0xD5,
0xC9, 0x7E, 0xD5, 0x11, 0xFB, 0xB6, 0x3F, 0xC6, 0xB4, 0xF8, 0xE7, 0x90,
0xE4, 0x91, 0x0F, 0xEB, 0x01, 0xC7, 0x00, 0xE3, 0x40, 0xFF, 0xA3, 0x15,
0xC7, 0xF8, 0x44, 0xE0, 0x89, 0x90, 0xE3, 0xFA, 0x04, 0x88, 0x73, 0xC5,
0xE5, 0xAF, 0xE6, 0x4A, 0xFA, 0x77, 0x98, 0x4E, 0xC6, 0xDF, 0xB6, 0x5A,
0xD5, 0x99, 0x21, 0xC9, 0x23, 0xCF, 0x66, 0x98, 0x7E, 0xC0, 0x44, 0x9D,
0x8C, 0x8F, 0xD3, 0xC9, 0x78, 0xF9, 0xCA, 0x6E, 0x4B, 0x75, 0x31, 0x70,
0x4F, 0x48, 0xD9, 0x78, 0x6B, 0xAF, 0x98, 0xB8, 0xBA, 0xAD, 0x7F, 0x8B,
0x99, 0x17, 0x0E, 0x63, 0x0B, 0xDE, 0xB0, 0x4D, 0xBC, 0x99, 0xA9, 0xD5,
0x30, 0x20, 0x22, 0x89, 0x74, 0x06, 0x46, 0x21, 0xD5, 0x51, 0x15, 0xFE,
0x96, 0x61, 0xCC, 0x52, 0xCB, 0xDA, 0x91, 0xF2, 0x51, 0x6B, 0x77, 0x7B,
0x5B, 0x95, 0xA0, 0xED, 0x1A, 0xE0, 0x6A, 0xD0, 0xCF, 0x58, 0x9B, 0x7C,
0x18, 0xA1, 0x7B, 0x8A, 0x35, 0xAF, 0xEC, 0x5A, 0xA2, 0xF7, 0x7E, 0xB9,
0x95, 0xC8, 0xE3, 0x14, 0xAB, 0x79, 0xD4, 0x84, 0xB8, 0x6D, 0x0C, 0x88,
0xD3, 0x73, 0x69, 0x48, 0xB6, 0xBD, 0x17, 0x80, 0x1F, 0x95, 0xA8, 0xCB,
0x47, 0x03, 0x6F, 0xE9, 0x64, 0xBC, 0xF5, 0x1D, 0x45, 0xA4, 0xBA, 0x14,
0xB8, 0x83, 0x70, 0x59, 0x3C, 0xEE, 0x5B, 0x4F, 0x88, 0xCB, 0xE6, 0x9C,
0x3D, 0x54, 0xB7, 0xF5, 0x0F, 0x31, 0x43, 0x20, 0x61, 0x8A, 0xCB, 0x6C,
0xD7, 0x66, 0xDE, 0xCE, 0x90, 0x48, 0x21, 0x9A, 0x48, 0xA5, 0x93, 0x48,
0x98, 0x7A, 0xF5, 0x0B, 0xDB, 0x11, 0x81, 0xB4, 0x46, 0x02, 0xCF, 0x21,
0xC0, 0xE3, 0x7A, 0xF8, 0x41, 0x81, 0x04, 0x83, 0x4E, 0xC6, 0x8F, 0x06,
0xFE, 0x5A, 0x04, 0x4D, 0x28, 0x1F, 0x56, 0x58, 0xAD, 0xA6, 0xD4, 0xE4,
0x71, 0x06, 0x26, 0xE1, 0x64, 0x18, 0xED, 0x76, 0x3C, 0x70, 0x8A, 0x48,
0xBC, 0x96, 0xAF, 0x14, 0xCE, 0x84, 0x56, 0x58, 0x7F, 0xDB, 0x01, 0xCF,
0xB7, 0xAA, 0x26, 0x22, 0xD5, 0x15, 0xC0, 0xAD, 0x21, 0xC9, 0xE3, 0xA1,
0x4E, 0x30, 0x6C, 0xC5, 0x39, 0x6D, 0x9F, 0x3C, 0xB2, 0x09, 0x24, 0xCC,
0xC1, 0xF8, 0x9E, 0x6D, 0xEA, 0x0D, 0x7D, 0xEF, 0x53, 0x4B, 0x22, 0xFF,
0x2B, 0x40, 0x13, 0x39, 0xB2, 0x42, 0xDF, 0x2E, 0x4C, 0x70, 0xE0, 0x37,
0xB4, 0x13, 0x88, 0x44, 0xEA, 0x79, 0x4C, 0xBD, 0xF9, 0x52, 0xE3, 0x78,
0x74, 0xFA, 0xBC, 0x00, 0x42, 0xB7, 0x17, 0xA6, 0xE0, 0x57, 0x55, 0x09,
0xFB, 0xF2, 0x58, 0xB1, 0x0F, 0xF9, 0x73, 0xBC, 0xC7, 0xB9, 0x56, 0xBB,
0x0B, 0x63, 0x8A, 0xF9, 0x27, 0xE0, 0x35, 0xE7, 0xD1, 0x26, 0x12, 0xA9,
0x85, 0xC0, 0xBD, 0xAD, 0x30, 0x5F, 0xDB, 0x01, 0x0F, 0xB6, 0x12, 0x79,
0x5C, 0x03, 0xDC, 0x10, 0x76, 0xE3, 0x53, 0x0D, 0xE7, 0xDA, 0x62, 0x79,
0xED, 0x02, 0x99, 0x85, 0x12, 0xC6, 0x9B, 0xE1, 0xA0, 0x36, 0xF7, 0x96,
0x86, 0x44, 0x06, 0x14, 0x40, 0x22, 0xA3, 0x2B, 0x94, 0x44, 0xE2, 0x21,
0xAE, 0x6D, 0x6F, 0xF5, 0xBB, 0xAF, 0x6D, 0xA5, 0xE7, 0x5C, 0x6F, 0x3D,
0xBC, 0x9A, 0xC3, 0xEF, 0x30, 0xDE, 0x55, 0xA5, 0x42, 0x1A, 0xB8, 0xB3,
0xC4, 0xE4, 0x71, 0x21, 0xF0, 0x40, 0x48, 0x12, 0x7C, 0x05, 0x38, 0x56,
0x24, 0x52, 0x41, 0x9C, 0x39, 0x6E, 0x07, 0x5A, 0xA3, 0x34, 0xEC, 0x31,
0x3A, 0x19, 0x1F, 0x58, 0x62, 0xF2, 0xB8, 0x0E, 0xF8, 0x63, 0xC8, 0xBB,
0xC6, 0xC4, 0xE0, 0x8C, 0x62, 0x94, 0xBA, 0xAE, 0x44, 0x02, 0x09, 0x13,
0xDB, 0x71, 0x80, 0x90, 0x6A, 0x93, 0x36, 0x4A, 0x22, 0x85, 0x68, 0x22,
0xA3, 0x91, 0x6A, 0x48, 0xC5, 0xEC, 0xC2, 0xA5, 0xEA, 0x1E, 0x92, 0x40,
0x3E, 0x6E, 0x57, 0xF4, 0x21, 0x62, 0xCF, 0x01, 0xEF, 0x04, 0x95, 0x8F,
0x05, 0x3C, 0xA9, 0x27, 0x70, 0x56, 0xDE, 0x86, 0x87, 0xF7, 0x8B, 0x11,
0xAE, 0x68, 0xD7, 0x3C, 0x4C, 0x89, 0xE6, 0xBB, 0x2C, 0x09, 0xDE, 0x00,
0xDC, 0x0F, 0xFC, 0x1D, 0x98, 0x95, 0xA7, 0xAF, 0x2F, 0x88, 0x44, 0xAA,
0x64, 0xD9, 0x94, 0x75, 0x32, 0xFE, 0x6B, 0xCC, 0x99, 0x52, 0x18, 0x53,
0xCC, 0x1B, 0xC0, 0xD1, 0x22, 0x91, 0x0A, 0x74, 0xB6, 0x26, 0x12, 0xA9,
0x79, 0x21, 0xB4, 0x90, 0x42, 0xCD, 0x3B, 0xBF, 0x2B, 0xC5, 0x38, 0x6D,
0x52, 0x3B, 0x4A, 0x20, 0xD5, 0x70, 0xC2, 0xD5, 0xE3, 0x01, 0x18, 0x1F,
0x83, 0x53, 0xD2, 0x26, 0x7B, 0x44, 0xBB, 0x42, 0x35, 0xC0, 0xFA, 0x42,
0x4C, 0x5B, 0xAE, 0xF5, 0x5C, 0x4C, 0x10, 0x4F, 0x4B, 0xA8, 0xD1, 0xF0,
0x0B, 0xFB, 0x01, 0xB4, 0x35, 0x12, 0xF9, 0x04, 0xA9, 0x06, 0x02, 0xFF,
0x06, 0x36, 0x8B, 0x40, 0x22, 0x63, 0x90, 0xEA, 0x78, 0x7C, 0x6F, 0x42,
0xB9, 0x5F, 0x45, 0x83, 0x87, 0x09, 0xB6, 0x0A, 0x82, 0x45, 0xD5, 0x30,
0xA5, 0xBE, 0x1D, 0x2D, 0x5C, 0x71, 0xF9, 0xAB, 0x5A, 0x27, 0xE3, 0x7F,
0xB2, 0x82, 0x37, 0x1B, 0x33, 0x30, 0x36, 0xF9, 0x37, 0xED, 0xC6, 0x68,
0x2E, 0x26, 0x80, 0xB0, 0x3B, 0xE6, 0x70, 0xFB, 0x70, 0xE0, 0x74, 0xC2,
0x39, 0x83, 0x9C, 0x02, 0xDC, 0x96, 0x7B, 0x22, 0xF4, 0x0E, 0x04, 0x77,
0x17, 0xBF, 0x01, 0xB8, 0xAE, 0x39, 0x73, 0x8F, 0x4E, 0xC6, 0xB7, 0xC4,
0x9C, 0xBF, 0x9C, 0x82, 0xF1, 0x32, 0x12, 0x98, 0x43, 0xDA, 0x52, 0x91,
0xC7, 0x65, 0xC0, 0x4D, 0x21, 0x6F, 0x7B, 0x17, 0x18, 0x22, 0x12, 0xA9,
0xB0, 0x1A, 0xC5, 0xAD, 0xC0, 0x45, 0x40, 0xB7, 0xAC, 0x9F, 0x2D, 0x00,
0x9E, 0xC3, 0x78, 0x67, 0xBD, 0x8F, 0x31, 0xB5, 0x2E, 0xC7, 0xB8, 0x9D,
0xF7, 0xC6, 0xC4, 0xF5, 0x9C, 0x05, 0xEC, 0x10, 0xE2, 0x39, 0x87, 0xE9,
0x64, 0x7C, 0x53, 0x91, 0x48, 0x7D, 0x57, 0xAC, 0x71, 0xEA, 0x5E, 0x3B,
0x4A, 0xCC, 0xD5, 0xFA, 0x36, 0xE0, 0x92, 0x90, 0xB7, 0xFE, 0x53, 0x80,
0x57, 0xA6, 0xB2, 0xD2, 0xF3, 0x80, 0xA7, 0xB2, 0xFE, 0xFF, 0xA3, 0x80,
0xE3, 0x38, 0x9E, 0xFC, 0xE1, 0x1A, 0x6B, 0xA5, 0x88, 0x17, 0x59, 0x6A,
0xD9, 0x28, 0x4C, 0x4D, 0x90, 0x20, 0x98, 0x56, 0x05, 0xBB, 0x37, 0xF8,
0x5E, 0xDB, 0x94, 0x49, 0x52, 0xED, 0x8E, 0xF1, 0x67, 0xDE, 0x34, 0xC2,
0xDD, 0x2B, 0x31, 0x35, 0xE0, 0x9F, 0x2B, 0x57, 0xF7, 0x3B, 0x4B, 0x15,
0x5B, 0x65, 0x3E, 0xB6, 0xA0, 0xE7, 0x51, 0xE3, 0xF1, 0xBD, 0xB2, 0x3B,
0x03, 0xE8, 0x64, 0x7C, 0x45, 0x40, 0xD2, 0x9B, 0x25, 0x12, 0xA9, 0xED,
0x5B, 0x6C, 0x6F, 0x44, 0x7F, 0x41, 0xBA, 0x6E, 0x12, 0x26, 0x40, 0x6F,
0x22, 0x30, 0x1C, 0x51, 0x95, 0x12, 0x97, 0xBF, 0xA2, 0x5B, 0xE8, 0xC7,
0x66, 0xC0, 0x68, 0xE0, 0xA7, 0x21, 0x4C, 0x48, 0x9B, 0x89, 0x44, 0x6A,
0x4E, 0x8E, 0xB6, 0x0E, 0x26, 0x98, 0x77, 0xD4, 0x1C, 0x44, 0xF5, 0xA6,
0xE2, 0xF2, 0x97, 0x75, 0x88, 0xF1, 0xDA, 0x1B, 0x38, 0x03, 0x11, 0xBB,
0x34, 0x97, 0x6B, 0x6C, 0x8E, 0xEB, 0xBF, 0x24, 0x98, 0x67, 0xDA, 0x52,
0x84, 0xD8, 0x10, 0xAD, 0x13, 0x40, 0xD8, 0x02, 0x5B, 0x1F, 0x01, 0x03,
0xA2, 0x9E, 0xC7, 0xE8, 0x64, 0x7C, 0x04, 0xA6, 0x44, 0xF4, 0x27, 0xC0,
0x8D, 0xC0, 0xE8, 0x96, 0x32, 0x02, 0xE8, 0x64, 0xBC, 0x13, 0x70, 0x33,
0xA6, 0x0E, 0x4F, 0x60, 0xD2, 0x17, 0x89, 0xD4, 0x13, 0xCD, 0xC8, 0x80,
0xA0, 0xF3, 0x30, 0x73, 0x03, 0x21, 0x76, 0x5C, 0xAA, 0xF5, 0x5D, 0x96,
0xFC, 0xC2, 0xE0, 0x55, 0x01, 0x43, 0xB4, 0xEF, 0x2D, 0x0D, 0x29, 0x9F,
0x7E, 0x44, 0xF0, 0x33, 0xBE, 0xC7, 0xF0, 0xBD, 0xD3, 0x02, 0xB6, 0xFB,
0x67, 0xE0, 0xDC, 0x00, 0x57, 0xEE, 0x80, 0xEF, 0xCD, 0x0C, 0x63, 0xC2,
0xC2, 0xEE, 0x02, 0x82, 0x62, 0xA7, 0x06, 0x38, 0xA3, 0xCD, 0x6E, 0x5F,
0x4D, 0x75, 0xBE, 0x01, 0x40, 0x94, 0x1D, 0x4A, 0x17, 0x60, 0x2C, 0x52,
0xFD, 0xAC, 0x5C, 0xDD, 0x5F, 0x05, 0x27, 0x12, 0xCE, 0x99, 0x61, 0x3C,
0xED, 0x10, 0xE2, 0x0F, 0x2F, 0x69, 0xE0, 0x4A, 0xE0, 0x28, 0x91, 0x48,
0x0D, 0x11, 0x89, 0xD4, 0x6B, 0x2D, 0x91, 0x87, 0x35, 0xA7, 0xFC, 0x0F,
0x13, 0x5B, 0x11, 0x54, 0x08, 0xC6, 0x80, 0xFD, 0xF2, 0xFC, 0x2E, 0xE8,
0x99, 0xC1, 0xC6, 0xE8, 0xFA, 0xC3, 0x43, 0xBD, 0x5F, 0x22, 0xF5, 0xBE,
0x48, 0xA4, 0x2E, 0x09, 0x42, 0x1E, 0xE1, 0x25, 0xB9, 0xFE, 0x63, 0x04,
0xF2, 0xF8, 0x0C, 0x18, 0x54, 0xE0, 0x61, 0xFE, 0xAD, 0xC0, 0xE5, 0x20,
0xFA, 0x88, 0x44, 0x6A, 0x64, 0x90, 0x74, 0x32, 0x22, 0x91, 0x5A, 0x8D,
0xA8, 0xBA, 0x04, 0x08, 0x53, 0x83, 0x68, 0xDF, 0x22, 0x8D, 0x54, 0x6C,
0xA9, 0xD6, 0xF7, 0x45, 0x20, 0x8F, 0x37, 0x05, 0x1C, 0x15, 0x9A, 0x3C,
0xDA, 0x18, 0x62, 0x59, 0xAA, 0xC8, 0x38, 0xC2, 0xC5, 0x83, 0xDC, 0x28,
0xA4, 0xEA, 0xD5, 0x66, 0xDF, 0xDC, 0x90, 0xC8, 0xC0, 0x88, 0x24, 0xD2,
0xB9, 0x5C, 0x24, 0x22, 0xA4, 0xDA, 0x88, 0x70, 0xD1, 0xA5, 0x75, 0x02,
0xC6, 0xB6, 0xD7, 0x05, 0x2C, 0x12, 0xA9, 0xE7, 0x45, 0x22, 0x35, 0x21,
0xC2, 0x7D, 0x73, 0x31, 0x9E, 0x53, 0x41, 0x91, 0xCF, 0x7D, 0x3D, 0x0C,
0x09, 0x4D, 0xD4, 0xC9, 0xF8, 0xCB, 0x3A, 0x19, 0xFF, 0x83, 0x4E, 0xC6,
0x07, 0xE9, 0x64, 0x7C, 0x0B, 0x9D, 0xEC, 0x57, 0x55, 0x86, 0x61, 0xDB,
0x00, 0x08, 0x1B, 0xC4, 0x36, 0x03, 0x38, 0x4C, 0x24, 0x52, 0xDF, 0x16,
0x38, 0x5F, 0xDF, 0x8B, 0x44, 0x6A, 0x44, 0x33, 0x2E, 0xBF, 0xB9, 0xEF,
0x33, 0x1B, 0x83, 0x5B, 0x43, 0xDC, 0xB2, 0x4B, 0x91, 0xC6, 0x6A, 0x5B,
0xE0, 0xBC, 0x90, 0xF7, 0xBC, 0x67, 0x35, 0x8F, 0xC5, 0xB4, 0x73, 0xAC,
0x89, 0xB6, 0xD5, 0xBE, 0x37, 0x1F, 0xA9, 0xC6, 0x61, 0x6C, 0xEB, 0x41,
0xD0, 0x4B, 0xC3, 0x5F, 0xD6, 0xAB, 0x55, 0x3F, 0x5F, 0x71, 0x8E, 0xD7,
0x36, 0x7D, 0x9A, 0x7D, 0x6F, 0x8A, 0x3D, 0x13, 0x79, 0x91, 0xF0, 0xC9,
0x06, 0x33, 0x9A, 0xC8, 0xB1, 0xF8, 0x5E, 0xAB, 0x44, 0x08, 0x77, 0xAF,
0x1D, 0x25, 0x16, 0x6B, 0xFD, 0x00, 0xE1, 0x52, 0xB4, 0x8C, 0xD7, 0xBE,
0x37, 0x87, 0x0E, 0x08, 0x3D, 0xFC, 0x20, 0x81, 0x4E, 0x77, 0x26, 0x7F,
0xAA, 0x9E, 0x30, 0xCE, 0x23, 0x5B, 0xE6, 0xF9, 0xF9, 0x34, 0x8C, 0x59,
0x33, 0x88, 0x69, 0x4E, 0xD0, 0x98, 0xAF, 0x2B, 0xD3, 0xCB, 0x65, 0x3A,
0x19, 0x9F, 0x66, 0x77, 0xF7, 0x53, 0x80, 0x49, 0xC0, 0x9B, 0x22, 0x91,
0x5A, 0x54, 0x41, 0x43, 0x39, 0xDB, 0x92, 0xC7, 0x57, 0xAD, 0x30, 0x5F,
0xD5, 0x76, 0x83, 0x96, 0x0B, 0x61, 0x92, 0x87, 0x6E, 0x5E, 0xA6, 0xB1,
0xFA, 0x48, 0xC0, 0x11, 0x15, 0x52, 0x74, 0xAE, 0xF5, 0x08, 0xC4, 0xE2,
0xEE, 0x10, 0x04, 0x02, 0x70, 0xD4, 0x0A, 0xCD, 0x0D, 0xC0, 0x15, 0x6D,
0x58, 0x13, 0x99, 0x92, 0x75, 0xB0, 0x1E, 0x85, 0x44, 0x9E, 0x6E, 0x2D,
0x12, 0x59, 0xAC, 0xF5, 0x95, 0x21, 0xE7, 0x27, 0x33, 0xA7, 0xED, 0x9F,
0x2C, 0x92, 0xF1, 0xDD, 0x30, 0x07, 0xE4, 0xFB, 0x01, 0x3F, 0x04, 0x36,
0x47, 0xA7, 0x37, 0x26, 0x5C, 0x34, 0x75, 0xB3, 0xFC, 0x9D, 0x67, 0x47,
0xBD, 0x42, 0x27, 0xE3, 0x13, 0x31, 0x39, 0xB9, 0xA2, 0xA0, 0x2B, 0x26,
0xF9, 0xE3, 0xDE, 0xD9, 0x5A, 0xA3, 0x4E, 0xC6, 0x5F, 0xC7, 0x44, 0x83,
0x3F, 0x21, 0x12, 0xA9, 0xE5, 0x65, 0x1E, 0xDE, 0x53, 0x45, 0x22, 0x35,
0xAB, 0xC8, 0xF3, 0xB5, 0x29, 0x30, 0x08, 0x53, 0x66, 0xB5, 0x0F, 0xD0,
0x1B, 0x9D, 0xDE, 0x84, 0xE0, 0x85, 0xED, 0x82, 0x7C, 0x9B, 0xAD, 0x8D,
0x34, 0x70, 0x7C, 0x47, 0xDA, 0xB0, 0xAD, 0x15, 0x30, 0xB4, 0xB1, 0x10,
0xAF, 0x03, 0xAF, 0x87, 0x6C, 0x23, 0x81, 0x54, 0xBF, 0x6F, 0xD3, 0xA3,
0xE0, 0x7B, 0x1F, 0x63, 0xCC, 0x59, 0x73, 0x23, 0x2E, 0xD4, 0xB1, 0x25,
0x77, 0xF1, 0x35, 0x29, 0x13, 0xC2, 0xC6, 0x3E, 0xBC, 0xD5, 0x53, 0x88,
0x97, 0xDB, 0x2F, 0x69, 0xF4, 0xAB, 0xD2, 0xC9, 0xF8, 0x99, 0x3A, 0x19,
0xFF, 0x00, 0x73, 0x28, 0x7B, 0x07, 0xC6, 0xA5, 0x76, 0x1F, 0x8C, 0x97,
0x5D, 0x4D, 0x11, 0x1F, 0xD7, 0x9C, 0x8B, 0xEB, 0x70, 0x2B, 0x3C, 0x8A,
0x85, 0x1A, 0x4C, 0x96, 0x5A, 0x09, 0xCC, 0xD0, 0xC9, 0xF8, 0xE9, 0x65,
0x1E, 0xEA, 0xC8, 0xB9, 0xC1, 0x72, 0x10, 0x47, 0x3F, 0x9D, 0x8C, 0x4F,
0xC0, 0x14, 0x80, 0xFB, 0x2B, 0x70, 0x01, 0xC6, 0x99, 0x61, 0xEB, 0x22,
0x92, 0x47, 0x39, 0xE5, 0xE9, 0x75, 0x3D, 0x6A, 0x47, 0x09, 0x3A, 0x08,
0xD6, 0x22, 0x90, 0x79, 0x26, 0xBC, 0xFE, 0xAA, 0x08, 0xED, 0x0C, 0x47,
0xAA, 0xCB, 0xDA, 0x01, 0x89, 0x1C, 0x4E, 0xF0, 0x32, 0xB1, 0xD9, 0x58,
0xCF, 0x6A, 0x22, 0xE7, 0x15, 0xBB, 0x5B, 0x55, 0x52, 0x55, 0x23, 0xD5,
0xAD, 0x98, 0x73, 0x8F, 0xB0, 0x0B, 0xF3, 0xBA, 0xF9, 0xED, 0x24, 0x65,
0x42, 0x0E, 0x41, 0xB4, 0x23, 0xE8, 0xB7, 0x30, 0xE7, 0x18, 0x7B, 0x95,
0xB3, 0x2F, 0x22, 0x91, 0x7A, 0x1B, 0xB8, 0xAE, 0x44, 0xCD, 0x6F, 0x06,
0x3C, 0xA2, 0x93, 0x71, 0x59, 0xA6, 0xF3, 0x12, 0x0C, 0x99, 0xE9, 0x11,
0x05, 0xCE, 0x57, 0x57, 0x9D, 0x8C, 0x3F, 0x82, 0x09, 0x3E, 0x1C, 0x4C,
0x69, 0xA3, 0xF6, 0xCB, 0x89, 0x13, 0x17, 0x6A, 0xFD, 0x9B, 0x0E, 0x49,
0x20, 0x56, 0x90, 0xBE, 0x0C, 0x8C, 0x89, 0xB0, 0x3B, 0x1B, 0x81, 0x54,
0xBF, 0x6B, 0xD3, 0xA3, 0xE1, 0x7B, 0x93, 0x81, 0xC3, 0x22, 0x92, 0x48,
0x0D, 0x70, 0x3F, 0x52, 0x3D, 0x25, 0xA4, 0x2A, 0x4E, 0xF1, 0x26, 0xA9,
0x76, 0x69, 0x30, 0x2E, 0xA2, 0x97, 0x46, 0x20, 0x8F, 0x7F, 0x55, 0x42,
0xBC, 0x4A, 0x89, 0xC8, 0x63, 0x37, 0x4C, 0x20, 0x5B, 0xDF, 0x4A, 0xE9,
0x93, 0x48, 0xA4, 0xFE, 0x88, 0x39, 0x98, 0x2E, 0x55, 0xA4, 0xF1, 0x39,
0xA0, 0xAF, 0x2B, 0xE3, 0x2B, 0xFE, 0xC6, 0x66, 0xE8, 0x8D, 0x32, 0x5F,
0xEB, 0x63, 0xDC, 0xAC, 0x4F, 0x8F, 0xB0, 0x8E, 0xDB, 0x22, 0x46, 0x20,
0xD5, 0x21, 0x1D, 0x93, 0x40, 0x0C, 0x2E, 0x21, 0x7C, 0xF2, 0x33, 0x01,
0xDC, 0xD4, 0x0E, 0x48, 0xE4, 0xBD, 0x02, 0x48, 0x04, 0xE0, 0x44, 0x0D,
0x9F, 0x21, 0xD5, 0xEF, 0x6D, 0xB4, 0x78, 0x14, 0xE2, 0xD8, 0xDC, 0x6A,
0x1D, 0x1F, 0x10, 0x3C, 0x56, 0x21, 0x1B, 0x2B, 0x08, 0xEF, 0x76, 0xD8,
0x56, 0xC8, 0xA3, 0x13, 0x26, 0x86, 0xA3, 0xE2, 0xB2, 0x21, 0x88, 0x44,
0xEA, 0x7A, 0xE0, 0x40, 0x4C, 0x7E, 0xA8, 0x52, 0x68, 0x7E, 0xBF, 0xB7,
0xE4, 0x59, 0x96, 0xD7, 0x03, 0x6A, 0x75, 0x32, 0xBE, 0x47, 0x84, 0x7B,
0x6F, 0xC1, 0x04, 0x45, 0x76, 0x14, 0x54, 0x03, 0x4F, 0x21, 0xD5, 0x56,
0x1D, 0xE1, 0x45, 0x73, 0x09, 0xD1, 0xAF, 0x90, 0xEA, 0x22, 0xE0, 0xF1,
0x88, 0x24, 0xA2, 0xF1, 0xBD, 0x5B, 0xDB, 0xEC, 0xA8, 0xF8, 0xDE, 0x7B,
0x48, 0x75, 0x38, 0x26, 0x05, 0x75, 0xCF, 0x08, 0x2D, 0x6C, 0x04, 0x8C,
0xD0, 0x70, 0x05, 0x52, 0xFD, 0x0D, 0x18, 0x2B, 0xE0, 0x35, 0xED, 0x7B,
0x79, 0x0F, 0x43, 0x85, 0x54, 0x1B, 0x69, 0x73, 0x0E, 0xE3, 0x61, 0xD2,
0x53, 0x77, 0x2E, 0xE0, 0x0D, 0x12, 0xB6, 0x26, 0x4A, 0x7B, 0xC4, 0x99,
0x84, 0xAB, 0xB3, 0xB1, 0x18, 0xE3, 0xD9, 0xF4, 0x55, 0x0E, 0xED, 0xA0,
0xB7, 0xDD, 0x2C, 0x14, 0x93, 0x44, 0x26, 0x01, 0x87, 0xEB, 0x64, 0x7C,
0x17, 0x3B, 0x97, 0x87, 0x5B, 0x4D, 0x69, 0xBD, 0x22, 0x34, 0x5F, 0x85,
0x71, 0x29, 0xFD, 0x75, 0x99, 0xC6, 0x7E, 0x03, 0x60, 0xB4, 0x4E, 0xC6,
0xF7, 0x0F, 0xEA, 0x25, 0x66, 0x4C, 0x8D, 0x81, 0x82, 0xD7, 0x32, 0xA8,
0x03, 0x26, 0x63, 0x6A, 0xBD, 0xAC, 0xCC, 0x21, 0x5F, 0x4E, 0xA7, 0x2D,
0xD4, 0x24, 0x32, 0x41, 0xCA, 0xA3, 0x62, 0x52, 0x1D, 0x9C, 0xF6, 0xBD,
0xD5, 0x1D, 0x8B, 0x40, 0x8C, 0x10, 0x1D, 0x89, 0x54, 0x07, 0x85, 0x9C,
0xFC, 0xCC, 0x24, 0xDF, 0x6C, 0x49, 0xE4, 0xB6, 0x36, 0x4C, 0x22, 0xEF,
0x5A, 0x12, 0xF9, 0x27, 0xD1, 0x8B, 0xDF, 0x6C, 0x68, 0xC7, 0xEF, 0x5C,
0x0D, 0x75, 0x48, 0xF5, 0x89, 0xFD, 0x30, 0x16, 0x58, 0x61, 0x56, 0x63,
0x77, 0xD2, 0xBB, 0x6A, 0x93, 0x62, 0xA0, 0x18, 0x76, 0xE1, 0xA7, 0x37,
0x14, 0xE2, 0xAE, 0x45, 0xB4, 0x5B, 0x9C, 0x16, 0xF0, 0xBA, 0x34, 0xC6,
0x3B, 0xF0, 0x8E, 0x7C, 0xC1, 0x6A, 0x3A, 0x19, 0x1F, 0x52, 0x6C, 0x02,
0xC9, 0x22, 0x92, 0xA9, 0x98, 0x73, 0x91, 0xEB, 0x6C, 0x85, 0xC2, 0xDD,
0x2C, 0xF1, 0x6D, 0x0F, 0xEC, 0x8C, 0x89, 0x2B, 0xD9, 0x05, 0xE3, 0xF9,
0x17, 0xC6, 0xAC, 0x73, 0x78, 0x99, 0xC7, 0x7F, 0x17, 0xE0, 0x11, 0x3D,
0xFC, 0xA0, 0xE3, 0x03, 0x06, 0x38, 0x9E, 0x1A, 0x62, 0x5D, 0x8F, 0x02,
0x86, 0xE5, 0x4B, 0x41, 0x62, 0xCE, 0x80, 0xF4, 0xA9, 0x6D, 0x84, 0x40,
0x00, 0x0E, 0x48, 0x1B, 0xC7, 0x8E, 0x0B, 0x3A, 0x1E, 0x81, 0x98, 0x59,
0x1A, 0x96, 0x36, 0x8B, 0xFD, 0x90, 0x08, 0x24, 0x72, 0x0B, 0x52, 0xD1,
0x0E, 0x48, 0xE4, 0xB0, 0x02, 0x49, 0x24, 0x83, 0x1A, 0x8C, 0xBB, 0x62,
0x9F, 0x12, 0xF6, 0xF8, 0x7D, 0x01, 0xA7, 0x2D, 0x6A, 0xBF, 0x07, 0xE7,
0x9D, 0x09, 0x5E, 0x94, 0xE8, 0x71, 0x91, 0x48, 0x8D, 0xA8, 0x84, 0x7E,
0x8B, 0x44, 0xAA, 0x0E, 0x13, 0xC3, 0xF0, 0x61, 0x8E, 0x77, 0xEA, 0x0D,
0x1C, 0x61, 0xC9, 0x6E, 0xDB, 0x00, 0xCD, 0x6D, 0xA3, 0x93, 0xFD, 0xAA,
0xC3, 0x06, 0xE2, 0x35, 0x83, 0xAF, 0x31, 0xF5, 0x72, 0xF6, 0x0B, 0x71,
0xCF, 0x31, 0xE8, 0xF4, 0xE5, 0x98, 0x54, 0x24, 0x2D, 0xE1, 0xC0, 0x80,
0x6D, 0xCE, 0xC6, 0xB8, 0x0B, 0x57, 0xF2, 0x6E, 0xFD, 0x33, 0x4B, 0x5E,
0x3B, 0x87, 0xB8, 0xE7, 0x7C, 0xA4, 0x7A, 0x1B, 0xDF, 0x7B, 0xA4, 0x0D,
0x7D, 0x6A, 0x81, 0xF3, 0xC4, 0x35, 0xCB, 0xE4, 0x69, 0xDF, 0x5B, 0x2D,
0x8C, 0x7F, 0xFB, 0x7B, 0x51, 0xBE, 0x1B, 0x4B, 0x22, 0x6D, 0xDB, 0x23,
0xC1, 0xF7, 0xDE, 0xB5, 0xBB, 0xBE, 0x05, 0x15, 0xDE, 0xD3, 0xE9, 0xC0,
0xE0, 0x76, 0x9E, 0x3A, 0xE1, 0x07, 0x04, 0x77, 0xCD, 0x7D, 0x29, 0xC0,
0x35, 0x9B, 0x15, 0xAB, 0x63, 0x7A, 0xC4, 0x21, 0x42, 0x27, 0xE3, 0x23,
0x75, 0x32, 0x3E, 0x50, 0x8F, 0x38, 0x24, 0xB0, 0x46, 0x21, 0x12, 0xA9,
0x2F, 0x45, 0x22, 0x25, 0x31, 0x09, 0x14, 0x83, 0xA0, 0x33, 0xE8, 0x62,
0x79, 0x30, 0x7D, 0x87, 0x49, 0xE9, 0x73, 0x0C, 0xE1, 0xB3, 0x54, 0x5F,
0xAB, 0x93, 0xF1, 0x20, 0xDA, 0x50, 0xD0, 0xA2, 0x67, 0x6F, 0xB6, 0x4C,
0x1E, 0x7A, 0x73, 0xCA, 0xE7, 0xBD, 0x35, 0xDD, 0x8E, 0xD5, 0x71, 0xC0,
0x92, 0x90, 0xF7, 0xDE, 0x87, 0x54, 0xFB, 0x56, 0xC0, 0xF7, 0xB3, 0x32,
0xE0, 0x75, 0x41, 0x73, 0x22, 0xB6, 0xAC, 0x0A, 0x6A, 0xDF, 0x5B, 0x28,
0x4C, 0xC0, 0xCF, 0x07, 0x11, 0x49, 0xE4, 0xD6, 0x76, 0x40, 0x22, 0xEF,
0x10, 0xDD, 0xC5, 0xB7, 0xB5, 0x76, 0x46, 0x03, 0xF1, 0xBD, 0x6F, 0x69,
0xDF, 0x08, 0x23, 0x3C, 0x36, 0x6F, 0x41, 0x9B, 0x59, 0x1F, 0x28, 0x9E,
0xEB, 0x79, 0xBA, 0xFE, 0x50, 0x4C, 0x96, 0xEA, 0x7F, 0x91, 0xAE, 0x9F,
0xA4, 0x93, 0xF1, 0x5F, 0x85, 0xAC, 0x8E, 0xD7, 0x23, 0xE0, 0x75, 0xAB,
0x41, 0x14, 0xC3, 0xD3, 0xEB, 0x7B, 0x60, 0xA0, 0x48, 0xA4, 0x3E, 0x17,
0x89, 0xD4, 0xD7, 0xC0, 0x50, 0xD3, 0x76, 0xA8, 0xB9, 0x78, 0x42, 0x27,
0xE3, 0x2D, 0x69, 0x4D, 0x41, 0x2B, 0x34, 0x06, 0x49, 0x6C, 0xFA, 0x47,
0xCA, 0x63, 0xBE, 0x9A, 0x01, 0x0C, 0xC0, 0xF7, 0xBE, 0xC5, 0xF7, 0xA6,
0x60, 0xF2, 0x00, 0x86, 0xD1, 0xF2, 0xD7, 0x03, 0x46, 0x0B, 0xA9, 0x36,
0x2E, 0xF3, 0xF7, 0x13, 0xB4, 0xA8, 0xDC, 0xEF, 0x90, 0xEA, 0x17, 0xD9,
0x3F, 0xA8, 0x96, 0xAA, 0x0A, 0xA9, 0xBC, 0xA6, 0xA5, 0x3C, 0x02, 0x4D,
0x86, 0xF6, 0xBD, 0xB9, 0xC2, 0xD8, 0x8A, 0x3F, 0x8C, 0xD0, 0xE9, 0x0C,
0x89, 0x5C, 0x42, 0x5B, 0x86, 0x21, 0x91, 0x7E, 0x98, 0x9A, 0x0D, 0x95,
0x84, 0x37, 0x04, 0x1C, 0x84, 0xEF, 0xCD, 0xA6, 0xFD, 0x63, 0x61, 0x88,
0x0F, 0xF7, 0x02, 0x9B, 0x75, 0x37, 0x17, 0x79, 0x74, 0x03, 0xFE, 0x46,
0xF1, 0xF2, 0x25, 0xC1, 0xDA, 0x69, 0xBE, 0xFB, 0x62, 0x8A, 0x33, 0x7D,
0xAB, 0x93, 0xF1, 0x37, 0x75, 0x32, 0x7E, 0xB3, 0x4E, 0xC6, 0x4F, 0xD5,
0xC9, 0xF8, 0xFE, 0x3A, 0x19, 0xDF, 0x5C, 0x27, 0xE3, 0x1B, 0xD8, 0xB8,
0x88, 0x5E, 0xF6, 0x67, 0xBF, 0x07, 0x9E, 0x09, 0x2A, 0xCC, 0x8A, 0x60,
0xBE, 0xAA, 0x03, 0x0E, 0x15, 0x89, 0xD4, 0x94, 0x2C, 0x4D, 0x28, 0x45,
0xF8, 0x54, 0xE5, 0x1B, 0x63, 0x0E, 0xD5, 0x9B, 0x73, 0x10, 0x08, 0x1A,
0x95, 0xDD, 0x2F, 0x5F, 0x21, 0x28, 0x3D, 0xFC, 0x20, 0xA1, 0x93, 0xF1,
0x2B, 0x80, 0xB3, 0xCB, 0xB0, 0xE6, 0x16, 0x5B, 0xF2, 0xF8, 0x32, 0x4B,
0x16, 0x8C, 0x05, 0x92, 0x21, 0xDB, 0xD9, 0x4E, 0xC3, 0xC8, 0x1A, 0xA9,
0xCA, 0x19, 0xFF, 0x12, 0x34, 0x75, 0x4F, 0x27, 0x60, 0x24, 0x52, 0x7D,
0x8A, 0x54, 0x63, 0x90, 0x6A, 0x62, 0xBD, 0x31, 0x75, 0xFE, 0x4D, 0x37,
0xC9, 0x84, 0x11, 0x38, 0xBA, 0x54, 0xFB, 0xDE, 0x1C, 0x21, 0xD5, 0xA1,
0x1A, 0xFE, 0x45, 0xF8, 0xC0, 0x2D, 0x01, 0xDC, 0x66, 0x0F, 0xD6, 0xEF,
0x6C, 0xB3, 0xE2, 0xCB, 0xF7, 0x3E, 0x11, 0x52, 0xFD, 0x58, 0x1B, 0xEF,
0xB4, 0x41, 0x15, 0xD0, 0x23, 0x19, 0x83, 0x61, 0x65, 0xAA, 0x35, 0xD0,
0xEA, 0x10, 0x89, 0xD4, 0x42, 0x9D, 0x8C, 0xCF, 0xC2, 0x1C, 0x44, 0xB7,
0x84, 0xAD, 0x80, 0x77, 0x74, 0x32, 0x7E, 0x0B, 0x90, 0xC2, 0xD4, 0x37,
0xE8, 0x09, 0xF4, 0xC7, 0x78, 0x32, 0x15, 0xCD, 0xC5, 0xD2, 0x7A, 0x5C,
0x1D, 0x91, 0x67, 0x97, 0xBE, 0xBF, 0xFD, 0xB3, 0xD6, 0x2D, 0xF6, 0x4F,
0x94, 0xDD, 0x74, 0x31, 0x62, 0x7B, 0x56, 0x21, 0xC4, 0x94, 0x1C, 0xE3,
0x7B, 0xBF, 0x4E, 0xC6, 0xFB, 0x86, 0x14, 0xD4, 0x7D, 0x81, 0x7B, 0x9A,
0xB9, 0x67, 0x72, 0x8E, 0xF7, 0xCF, 0x67, 0x0D, 0x99, 0xA0, 0x93, 0xF1,
0x7B, 0x31, 0x89, 0x5D, 0xBF, 0xB7, 0x3B, 0xF7, 0x7D, 0xD1, 0xE9, 0x73,
0x81, 0x1F, 0x97, 0x69, 0xD9, 0xCD, 0xCD, 0xB5, 0x39, 0xAB, 0x81, 0xAB,
0xEB, 0x4C, 0xFA, 0x99, 0xC1, 0x21, 0xDA, 0x1A, 0x54, 0x67, 0xB4, 0xA8,
0xAB, 0xCB, 0xF2, 0xFD, 0xC0, 0xDB, 0xDA, 0x38, 0x97, 0x04, 0x5D, 0x77,
0xBB, 0xDA, 0x3F, 0xD9, 0xF8, 0x05, 0x70, 0x5F, 0x28, 0x0D, 0x64, 0x2D,
0x12, 0x81, 0x43, 0x31, 0x35, 0x01, 0xA2, 0x68, 0x22, 0xB7, 0x23, 0xD5,
0xC5, 0x6D, 0x59, 0x88, 0x69, 0xDF, 0x9B, 0xDB, 0xD9, 0x2C, 0x9A, 0xDF,
0x00, 0xCB, 0xCA, 0xD4, 0x8D, 0x39, 0x80, 0x87, 0xEF, 0x9D, 0xDB, 0x51,
0xC8, 0x23, 0x0B, 0x7F, 0x0F, 0x71, 0xED, 0x96, 0x98, 0x52, 0xAA, 0x93,
0x80, 0x4F, 0x31, 0x69, 0x7A, 0xAE, 0x2F, 0x26, 0x79, 0x58, 0x5C, 0x1C,
0xF2, 0x5B, 0x12, 0x11, 0xC9, 0x63, 0x15, 0xA6, 0x7A, 0x61, 0x29, 0x71,
0x11, 0xE1, 0xEB, 0xCD, 0x9F, 0xA5, 0x93, 0xF1, 0x5F, 0x15, 0x61, 0xBE,
0x3A, 0x63, 0x82, 0x66, 0x5F, 0xB1, 0xF3, 0xF5, 0x1E, 0xA6, 0x46, 0xFB,
0x8F, 0x2B, 0x6D, 0x11, 0xD6, 0xF9, 0x5E, 0x5A, 0x18, 0x0F, 0xB3, 0xE9,
0x21, 0x6F, 0xBD, 0x02, 0xA9, 0x8E, 0x2E, 0x93, 0xEC, 0xFA, 0xDE, 0x6E,
0xA6, 0x0A, 0xC1, 0x4F, 0x90, 0x6A, 0xDB, 0x48, 0x04, 0x52, 0x24, 0x12,
0xB9, 0xA3, 0xAD, 0x93, 0xC8, 0x2A, 0xDF, 0x4B, 0xE3, 0x7B, 0x77, 0x60,
0x6A, 0x72, 0x8C, 0xA1, 0x34, 0x41, 0x63, 0xB9, 0xD0, 0x00, 0x3C, 0x20,
0x60, 0x77, 0x7C, 0x6F, 0x14, 0x1D, 0x13, 0xF7, 0x5A, 0x13, 0x4C, 0x65,
0x6C, 0x28, 0x92, 0xF1, 0x8D, 0x08, 0xEE, 0x5A, 0x5C, 0x28, 0x6E, 0x10,
0x89, 0xD4, 0x17, 0x25, 0xD6, 0xF2, 0x4C, 0xC1, 0xB4, 0xF0, 0x87, 0xEA,
0x77, 0xEA, 0x64, 0x3C, 0x87, 0xA6, 0x21, 0x9E, 0xB7, 0x64, 0xD0, 0xEE,
0xA0, 0x7D, 0x6F, 0x01, 0xC6, 0xC9, 0x28, 0x8C, 0xE3, 0x4A, 0x0C, 0xF8,
0x2B, 0x52, 0xED, 0x5C, 0xA6, 0x6E, 0x17, 0x5A, 0x49, 0x56, 0x00, 0x27,
0x47, 0x26, 0x90, 0x0C, 0x93, 0x15, 0x81, 0x44, 0x86, 0xB5, 0xF9, 0x15,
0xE4, 0x7B, 0xB3, 0xF0, 0xBD, 0x13, 0x30, 0xAE, 0x8A, 0xE3, 0x29, 0x6E,
0x42, 0xBD, 0xB5, 0x36, 0x3C, 0x98, 0xCC, 0xAC, 0x7B, 0xE2, 0x7B, 0xE7,
0x6B, 0xDF, 0x9B, 0x4B, 0x07, 0x85, 0x48, 0xA4, 0xA6, 0x11, 0xDE, 0xFE,
0xDC, 0x1C, 0x66, 0x14, 0x78, 0xFF, 0x39, 0x98, 0x00, 0xBB, 0x52, 0x43,
0x81, 0xB8, 0xB1, 0x95, 0xC6, 0xF8, 0x2B, 0x4C, 0xD1, 0xB2, 0x30, 0x44,
0xDD, 0x19, 0x18, 0xA5, 0x93, 0xF1, 0x1F, 0xAC, 0xDD, 0xD6, 0x6B, 0x0D,
0xC0, 0xF9, 0x14, 0x2F, 0xC5, 0xCB, 0xCC, 0x0A, 0x93, 0x01, 0x1F, 0x63,
0x4A, 0xEE, 0x86, 0xD9, 0x44, 0xF6, 0xC0, 0x1C, 0xAA, 0x77, 0x6D, 0xED,
0xEE, 0x76, 0x11, 0x3C, 0x4D, 0xB0, 0x0A, 0x9A, 0xCD, 0xA1, 0x30, 0x02,
0xC9, 0x52, 0x87, 0x0E, 0x05, 0x3E, 0x8E, 0x48, 0x22, 0x77, 0xDA, 0x68,
0xF7, 0xB6, 0x0F, 0xDF, 0x7B, 0xCB, 0x96, 0x8C, 0xDD, 0x0D, 0x18, 0x81,
0xF1, 0x69, 0x2F, 0x06, 0xA6, 0x02, 0xFF, 0x07, 0x6C, 0x8F, 0xEF, 0x9D,
0x8A, 0xEF, 0xB5, 0xCB, 0x9D, 0x5C, 0x84, 0xE5, 0x73, 0x2D, 0x6B, 0xD7,
0x7A, 0x8E, 0x8A, 0xD1, 0xC0, 0xD3, 0x05, 0xB6, 0x71, 0x40, 0xA9, 0x37,
0xBA, 0xC0, 0xED, 0x20, 0x4E, 0xB1, 0xC2, 0xB8, 0xB5, 0x88, 0xFA, 0x55,
0x8C, 0x39, 0x29, 0x0C, 0x7A, 0x03, 0x4F, 0x35, 0xCD, 0xDC, 0x2B, 0x12,
0xA9, 0x57, 0x80, 0x0B, 0x8B, 0xB0, 0xC1, 0xFA, 0x82, 0x4A, 0x2C, 0x1D,
0x61, 0xAC, 0x01, 0x61, 0x63, 0x8E, 0xF6, 0xD4, 0x50, 0xBB, 0x49, 0x2B,
0x67, 0xEE, 0x5D, 0x79, 0x8E, 0x97, 0x06, 0x4E, 0xB1, 0x63, 0x19, 0x15,
0x7B, 0x22, 0xD5, 0x9E, 0x05, 0x11, 0x88, 0x1D, 0xB8, 0xEF, 0x31, 0xE9,
0x37, 0xA2, 0x92, 0xC8, 0x5D, 0xED, 0x86, 0x44, 0xCC, 0x78, 0x7C, 0x8E,
0xEF, 0x5D, 0xDE, 0xD9, 0x44, 0x19, 0xF7, 0x05, 0x2E, 0xC7, 0x1C, 0x7A,
0x7E, 0x15, 0x50, 0x50, 0xCC, 0xB2, 0x02, 0xED, 0x37, 0xC0, 0x0F, 0x7B,
0x0A, 0xB1, 0x1B, 0xBE, 0x77, 0x1D, 0xBE, 0xF7, 0x15, 0x0E, 0x4D, 0x76,
0xB5, 0xE2, 0x54, 0xE0, 0x06, 0x20, 0xAA, 0x47, 0xD2, 0x18, 0xE0, 0x97,
0x14, 0x6A, 0x7E, 0x14, 0xB1, 0xA1, 0xC0, 0xCF, 0x31, 0x45, 0xC9, 0x8A,
0xAD, 0x81, 0xBE, 0x8B, 0xA9, 0x3F, 0x7E, 0x69, 0x11, 0x03, 0x07, 0xC3,
0x90, 0xC8, 0x3D, 0xC0, 0x23, 0x21, 0x6F, 0xEB, 0x0F, 0x3A, 0x99, 0xA3,
0xAD, 0x3F, 0x63, 0x4C, 0x63, 0xF3, 0x0A, 0xD8, 0x4C, 0x1D, 0x56, 0xC0,
0xFD, 0x25, 0x45, 0xB5, 0xC9, 0x62, 0xFE, 0x7C, 0xC8, 0xDB, 0x4E, 0x9A,
0xAB, 0x75, 0xEB, 0xA7, 0xA5, 0xF1, 0xBD, 0x6F, 0x30, 0x45, 0xCD, 0xDE,
0x2E, 0xA0, 0x95, 0x5F, 0x14, 0x4E, 0x20, 0x6B, 0x93, 0xC8, 0x94, 0x28,
0x6B, 0xD4, 0x92, 0xC8, 0x85, 0xED, 0x49, 0xC0, 0xD9, 0x33, 0x92, 0xF7,
0xF0, 0xBD, 0x11, 0xF8, 0xDE, 0x91, 0xF8, 0x5E, 0x6F, 0x61, 0xD4, 0xD6,
0x3E, 0x98, 0xA8, 0xFE, 0xA3, 0x30, 0x7E, 0xF7, 0x47, 0x02, 0x07, 0x01,
0x7B, 0x08, 0xE8, 0x86, 0xEF, 0x6D, 0x8F, 0xEF, 0x1D, 0x87, 0xEF, 0xDD,
0x81, 0xEF, 0x7D, 0xD2, 0x5E, 0x53, 0xB1, 0x17, 0x8B, 0x44, 0x44, 0x22,
0x75, 0x15, 0xA6, 0xF6, 0xC7, 0x93, 0x04, 0x2F, 0xC7, 0x3C, 0x15, 0x38,
0x0D, 0x51, 0x35, 0xD4, 0xDA, 0xFB, 0x0B, 0xEB, 0xC7, 0xE5, 0xAF, 0xA6,
0x45, 0x22, 0xF5, 0xAC, 0x48, 0xA4, 0x06, 0x62, 0xBC, 0xC3, 0x2E, 0xC1,
0x98, 0x33, 0xA3, 0x66, 0x93, 0x99, 0x07, 0x3C, 0x0A, 0x0C, 0x44, 0x54,
0xFF, 0x48, 0x24, 0x52, 0x2F, 0x97, 0x79, 0xA8, 0xCF, 0xC7, 0x38, 0x21,
0x84, 0xC1, 0x6F, 0x75, 0x32, 0x3E, 0x34, 0x07, 0x89, 0x3C, 0x83, 0x71,
0x9D, 0x1E, 0x4E, 0x70, 0xF7, 0xDE, 0x05, 0x98, 0x94, 0x30, 0x7D, 0x45,
0x22, 0x35, 0xBD, 0x52, 0xD7, 0x63, 0xBD, 0xEF, 0x35, 0x08, 0x23, 0x54,
0xC3, 0x9A, 0x44, 0x6F, 0x42, 0xAA, 0x83, 0xCB, 0x40, 0x22, 0xB3, 0xAB,
0x4C, 0xA2, 0xD6, 0x73, 0x09, 0x77, 0x46, 0xB5, 0x04, 0xE3, 0xD8, 0xF0,
0x60, 0x46, 0x80, 0x17, 0x07, 0x52, 0x6D, 0x8A, 0xA9, 0xEA, 0xF7, 0xC3,
0x88, 0x6A, 0xFA, 0x30, 0x7C, 0xEF, 0x5E, 0x27, 0x1A, 0xDB, 0x2F, 0x74,
0x32, 0x7E, 0x60, 0xC0, 0x4D, 0xCB, 0x4A, 0x91, 0x48, 0xBD, 0x1B, 0xB2,
0xED, 0xEE, 0x76, 0x23, 0xB3, 0x2F, 0x26, 0xD5, 0x44, 0xA6, 0x1A, 0xE1,
0x0A, 0xCC, 0x81, 0xF0, 0x47, 0xC0, 0x6B, 0x88, 0xD8, 0x3B, 0xD9, 0x39,
0x9C, 0x6C, 0x20, 0xDC, 0x96, 0x01, 0x1E, 0xF1, 0xAD, 0x48, 0xA4, 0x66,
0x06, 0xEF, 0x4F, 0xBF, 0x2A, 0xD0, 0x3B, 0xD8, 0x4D, 0xC3, 0x76, 0x98,
0x34, 0x25, 0x3F, 0xC0, 0x54, 0x36, 0xEC, 0x6C, 0xB5, 0x95, 0xE5, 0x96,
0x30, 0x66, 0x63, 0x4A, 0xE3, 0x4E, 0x06, 0x31, 0x25, 0xAA, 0xA9, 0x4A,
0x27, 0xE3, 0xFB, 0x11, 0x2C, 0x09, 0x67, 0x83, 0x48, 0xA4, 0xDE, 0x0C,
0xD1, 0xEE, 0x26, 0x84, 0x8F, 0x99, 0x59, 0x26, 0x12, 0xA9, 0xF7, 0x9B,
0x19, 0x9F, 0x1A, 0xD0, 0x3F, 0xC1, 0xB8, 0xF8, 0xEE, 0x8E, 0x09, 0x24,
0x5C, 0x1F, 0x73, 0xEE, 0x32, 0x17, 0x13, 0x1C, 0xFB, 0x36, 0xF0, 0x62,
0x36, 0xD1, 0xEB, 0x64, 0xBC, 0x47, 0x40, 0x19, 0xB3, 0x5C, 0x24, 0x52,
0x93, 0x9B, 0x91, 0x57, 0x41, 0xB3, 0x5C, 0xAF, 0xB4, 0xD9, 0x28, 0x82,
0xCA, 0xC1, 0x2D, 0xEC, 0x7C, 0x87, 0xC1, 0xFC, 0x6C, 0xF3, 0xB4, 0x90,
0x6A, 0x03, 0x1D, 0x3C, 0xE5, 0xD1, 0xF7, 0xF8, 0xDE, 0xB4, 0x42, 0xBE,
0xCB, 0x6E, 0xB5, 0xA3, 0xC4, 0x12, 0xAD, 0x7F, 0x08, 0xC4, 0x81, 0x3D,
0x30, 0xA6, 0xC8, 0x6E, 0xF6, 0x5B, 0x5D, 0x6E, 0xBF, 0x9F, 0xCF, 0x80,
0xC9, 0x02, 0x52, 0xDA, 0xF7, 0x56, 0x64, 0x6B, 0x00, 0xC5, 0x43, 0xE1,
0x24, 0x72, 0x11, 0xBE, 0x77, 0x9F, 0x13, 0xB5, 0x0E, 0x0E, 0x0E, 0x0E,
0x6D, 0xC0, 0x12, 0x50, 0xF4, 0x16, 0x0D, 0x89, 0xBC, 0x48, 0xB8, 0x94,
0xDB, 0xD9, 0x24, 0x72, 0x21, 0xBE, 0x77, 0xBF, 0x9B, 0x1A, 0x07, 0x07,
0x07, 0x87, 0xCA, 0x46, 0xF1, 0xF3, 0xCA, 0xF8, 0xDE, 0x77, 0xD6, 0x94,
0xF0, 0x49, 0x44, 0x42, 0xBB, 0x17, 0xA9, 0xCE, 0x77, 0x53, 0xE3, 0xE0,
0xE0, 0xE0, 0xD0, 0xD1, 0x34, 0x90, 0x46, 0x4D, 0x64, 0x33, 0xAB, 0x89,
0x44, 0xA9, 0xA0, 0xA6, 0x81, 0x0B, 0xF0, 0xBD, 0x07, 0xDC, 0x14, 0x39,
0x38, 0x38, 0x38, 0x74, 0x34, 0x02, 0x71, 0x24, 0xE2, 0xE0, 0xE0, 0xE0,
0xE0, 0x08, 0xA4, 0x8C, 0x24, 0x92, 0xB6, 0x24, 0xF2, 0x67, 0x37, 0x55,
0x0E, 0x0E, 0x0E, 0x0E, 0x1D, 0x8D, 0x40, 0x0C, 0x89, 0x6C, 0x6E, 0x49,
0x64, 0x57, 0x47, 0x22, 0x0E, 0x0E, 0x0E, 0x0E, 0x8E, 0x40, 0x5A, 0x9B,
0x44, 0xCE, 0xC7, 0xF7, 0x1E, 0xAC, 0xC8, 0x51, 0x94, 0xEA, 0x2C, 0x4C,
0xF4, 0xF8, 0xB3, 0xC0, 0xF3, 0x31, 0x78, 0x3B, 0xED, 0x7B, 0x2B, 0xDD,
0xF2, 0x72, 0x70, 0x70, 0x70, 0x04, 0x52, 0x5C, 0x12, 0x79, 0x89, 0x68,
0x85, 0x7C, 0xD2, 0xC0, 0x79, 0xF8, 0x9E, 0xAC, 0x30, 0xF2, 0x38, 0x13,
0x13, 0x99, 0x99, 0xED, 0xD1, 0xB6, 0x12, 0x78, 0x07, 0x53, 0xC5, 0xF1,
0x03, 0x4C, 0xDE, 0x99, 0xD9, 0x02, 0xBE, 0x8E, 0xC1, 0xCA, 0x2A, 0x48,
0xC7, 0x04, 0x54, 0x23, 0xF4, 0x52, 0x17, 0x6D, 0xEE, 0xE0, 0xE0, 0xE0,
0x08, 0x24, 0xB0, 0xC0, 0xDD, 0xC2, 0x6A, 0x22, 0x6D, 0x9F, 0x44, 0x72,
0x93, 0x47, 0x4B, 0x58, 0x85, 0x29, 0x1D, 0x3A, 0x15, 0x38, 0xD2, 0xBA,
0x3D, 0x3B, 0x38, 0x38, 0x38, 0x38, 0x02, 0x69, 0x25, 0x12, 0xF9, 0x15,
0xBE, 0x57, 0x5B, 0x66, 0xF2, 0x38, 0xC3, 0x92, 0x47, 0x94, 0x12, 0x95,
0xD3, 0x81, 0xFE, 0x2E, 0x49, 0xA2, 0x83, 0x83, 0x83, 0x23, 0x90, 0xE8,
0x24, 0xF2, 0x12, 0x26, 0x6F, 0x51, 0xDB, 0x22, 0x11, 0x53, 0x70, 0xFE,
0x31, 0xA2, 0x05, 0x62, 0x4E, 0xA7, 0x69, 0x8D, 0x65, 0x07, 0x07, 0x07,
0x87, 0x36, 0x88, 0x58, 0xD9, 0x9E, 0x6C, 0x52, 0x0A, 0xF7, 0x07, 0x3E,
0x8F, 0xD8, 0xEF, 0x3F, 0x23, 0xD5, 0xD9, 0xAD, 0xDE, 0x6F, 0xA9, 0x4E,
0x02, 0xFE, 0x1A, 0x71, 0xEC, 0x66, 0x38, 0xF2, 0x70, 0x68, 0x6F, 0xA8,
0x92, 0xAA, 0x7A, 0xE3, 0x56, 0xAE, 0x6B, 0xE1, 0xD0, 0xD1, 0x35, 0x90,
0x46, 0x81, 0xBC, 0x25, 0xC6, 0x9C, 0x15, 0x55, 0x13, 0xF1, 0xF1, 0xBD,
0x87, 0x5A, 0xA9, 0xAF, 0x27, 0x02, 0x8F, 0x03, 0xD5, 0x11, 0xC9, 0xA3,
0x7F, 0x87, 0x26, 0x0F, 0xA9, 0x3E, 0xC4, 0x64, 0x8B, 0x7D, 0x0A, 0xDF,
0xBB, 0xA6, 0x03, 0xBE, 0xFF, 0x79, 0x18, 0x6F, 0x3D, 0x11, 0x83, 0x3D,
0xD2, 0xBE, 0xB7, 0xBA, 0xCD, 0xEE, 0x3C, 0xA5, 0xEA, 0x94, 0x36, 0x35,
0x30, 0x4E, 0x07, 0x36, 0xC7, 0x94, 0x75, 0x7D, 0x14, 0xDF, 0xBB, 0xA4,
0x03, 0xCE, 0x6B, 0x3F, 0x8C, 0x39, 0x1B, 0xE0, 0x64, 0x7C, 0xEF, 0x3D,
0xA7, 0x81, 0xB4, 0x9E, 0x26, 0xF2, 0x35, 0x30, 0xA0, 0x00, 0x4D, 0x44,
0x5A, 0x37, 0x5A, 0x47, 0x1E, 0x95, 0x8F, 0x9D, 0xEC, 0x46, 0x61, 0xD3,
0x0E, 0xFA, 0xFE, 0x3D, 0xED, 0xFB, 0xEF, 0x54, 0x11, 0x9B, 0xB7, 0x02,
0x90, 0x36, 0xB5, 0xE9, 0xAF, 0x06, 0xB6, 0xC6, 0xA4, 0xCD, 0xDF, 0x88,
0x68, 0xE7, 0x81, 0xED, 0x01, 0x5D, 0xED, 0xBC, 0xEE, 0x0C, 0x74, 0xE9,
0x48, 0x2F, 0x1E, 0xAB, 0x88, 0x5E, 0x34, 0x92, 0xC8, 0xB4, 0x88, 0xEF,
0x20, 0xAD, 0x47, 0x54, 0xA9, 0xC8, 0xC3, 0x2B, 0x80, 0x3C, 0x66, 0xE2,
0xCC, 0x56, 0x0E, 0xED, 0xC9, 0x6C, 0x21, 0x55, 0x4F, 0xE0, 0x0C, 0xFB,
0xDF, 0x31, 0x98, 0x7A, 0x2A, 0x7D, 0x81, 0x3B, 0xDD, 0xE8, 0x74, 0x2C,
0xC4, 0x2A, 0xA6, 0x27, 0x85, 0x93, 0x48, 0x6D, 0x49, 0x48, 0xC4, 0x90,
0xC7, 0xC8, 0x02, 0xC8, 0xA3, 0x3F, 0xBE, 0x37, 0xDB, 0x2D, 0x35, 0x87,
0xF6, 0x02, 0x0D, 0x3B, 0x66, 0x7D, 0x0F, 0x0F, 0xE0, 0x7B, 0xDF, 0xD8,
0x0A, 0x9C, 0xD3, 0xDD, 0xE8, 0x74, 0x2C, 0x54, 0x57, 0x54, 0x6F, 0x7C,
0xEF, 0x2B, 0xA4, 0x1A, 0x80, 0x39, 0x13, 0xD9, 0x29, 0x22, 0x89, 0x68,
0x7C, 0xEF, 0x91, 0x22, 0x91, 0xC7, 0xD0, 0x02, 0x35, 0x0F, 0x47, 0x1E,
0xF9, 0xC7, 0x76, 0x0F, 0xE0, 0x02, 0x60, 0x6F, 0x8C, 0x39, 0xE7, 0x53,
0x60, 0x24, 0xBE, 0xF7, 0xEF, 0xAC, 0x6B, 0xEE, 0x03, 0x7A, 0x01, 0x53,
0xF1, 0xBD, 0xAB, 0xF2, 0xB4, 0x73, 0x13, 0xA6, 0x02, 0x5C, 0x0A, 0xDF,
0xBB, 0xB3, 0x99, 0xE7, 0xDD, 0x85, 0xB1, 0xD5, 0xFF, 0x03, 0x78, 0xDD,
0x3E, 0xBB, 0xAF, 0x5D, 0x37, 0x1F, 0x03, 0xF7, 0xE1, 0x7B, 0x93, 0x63,
0x52, 0xD5, 0xA4, 0xE1, 0x54, 0x4C, 0x9D, 0xF3, 0x2D, 0x80, 0x65, 0xC0,
0xCB, 0x02, 0xEE, 0xD6, 0xBE, 0x37, 0x7F, 0xAD, 0x05, 0x67, 0xAE, 0x3D,
0x1A, 0x18, 0x84, 0xC9, 0xB0, 0xB0, 0x1E, 0xA6, 0x94, 0xED, 0x1B, 0xC0,
0x83, 0xA1, 0xB4, 0x4E, 0xA9, 0xB6, 0x07, 0x6E, 0xC4, 0x98, 0x81, 0xE6,
0x08, 0xF8, 0x6D, 0xA6, 0xF2, 0x9B, 0x90, 0xAA, 0x9B, 0x36, 0x3B, 0xFE,
0x41, 0x18, 0xF3, 0xDF, 0x62, 0xE0, 0x55, 0xFB, 0x8C, 0x6F, 0x9B, 0xB4,
0x33, 0x18, 0x38, 0x13, 0x58, 0x19, 0x83, 0xB3, 0xD3, 0x70, 0x1A, 0xA6,
0x8F, 0x73, 0xF1, 0xBD, 0xB3, 0x00, 0x6A, 0xA4, 0xAA, 0xAA, 0x83, 0xE3,
0x80, 0x63, 0x30, 0x65, 0x78, 0x63, 0x98, 0x12, 0xB3, 0x93, 0x80, 0x67,
0xF0, 0xBD, 0x0F, 0x72, 0x75, 0x71, 0xA3, 0xDA, 0x51, 0x62, 0x81, 0xD6,
0xF7, 0xD8, 0x71, 0xC9, 0x60, 0x98, 0xFD, 0x4E, 0x00, 0xAE, 0xCB, 0xB8,
0xA6, 0x57, 0x49, 0x55, 0xD3, 0x00, 0x9E, 0x1D, 0xC7, 0x6D, 0x80, 0x06,
0xE0, 0x43, 0xCC, 0xF9, 0xD7, 0xCB, 0x39, 0xDE, 0x5F, 0x62, 0xCA, 0x3E,
0x3F, 0x6D, 0xFB, 0x71, 0x1E, 0xF0, 0x23, 0xE0, 0x2A, 0x7C, 0xEF, 0xD5,
0x66, 0xC6, 0xED, 0x12, 0x4C, 0x69, 0xD6, 0xA9, 0x02, 0xEE, 0xD2, 0x70,
0x21, 0xA6, 0xDE, 0xF7, 0xFA, 0xC0, 0x2C, 0xE0, 0x11, 0x7C, 0x6F, 0x62,
0xCF, 0xDA, 0x51, 0x62, 0xBE, 0xD6, 0x3F, 0xC3, 0x94, 0x9D, 0xDD, 0xD1,
0xF6, 0x67, 0x12, 0x70, 0x4F, 0x53, 0xE2, 0xB3, 0x15, 0xFA, 0x0E, 0xB6,
0xE3, 0xD6, 0x07, 0x53, 0x45, 0x72, 0x05, 0xF0, 0x3E, 0xF0, 0x30, 0xBE,
0x37, 0x39, 0x84, 0xB6, 0xD6, 0x4D, 0xC3, 0x2D, 0x18, 0xF3, 0x65, 0x1D,
0x70, 0x99, 0xDD, 0x2C, 0x53, 0x25, 0x55, 0x75, 0x83, 0x29, 0x6D, 0x7D,
0xAC, 0x1D, 0xA3, 0xD5, 0x98, 0x00, 0xE4, 0x87, 0xF0, 0xBD, 0x8F, 0x9A,
0xBC, 0xE7, 0xAE, 0x98, 0xD2, 0xBE, 0x60, 0xCA, 0x26, 0xEF, 0x63, 0xDF,
0x65, 0xCB, 0x1A, 0xE8, 0x5F, 0xE7, 0x7B, 0x69, 0x7B, 0xDD, 0xC1, 0xC0,
0xC9, 0x98, 0xA2, 0x7E, 0x5D, 0x80, 0x85, 0x98, 0x60, 0xE6, 0x89, 0xEB,
0x09, 0xF1, 0xE2, 0x8A, 0x22, 0x07, 0x2E, 0x8B, 0x0A, 0x15, 0x2E, 0x5B,
0x61, 0x5C, 0x7C, 0x77, 0x8C, 0x70, 0x77, 0x1A, 0x38, 0xBB, 0x60, 0x12,
0x91, 0xEA, 0x04, 0x4C, 0xAD, 0xED, 0x28, 0xE4, 0x31, 0xCB, 0x92, 0xC7,
0x7F, 0x1D, 0x53, 0xAC, 0x35, 0xA6, 0x2B, 0xEC, 0xA2, 0xFE, 0x1C, 0x53,
0xE2, 0xB5, 0x53, 0x8E, 0xAB, 0x1E, 0xAF, 0x82, 0xB3, 0x1A, 0x7C, 0xAF,
0x0E, 0xA9, 0xAE, 0x07, 0xAE, 0xB4, 0x1F, 0xFB, 0x2E, 0xF8, 0xDE, 0x8C,
0x26, 0xED, 0xED, 0x6E, 0x85, 0xBF, 0x00, 0x06, 0xE3, 0x7B, 0x13, 0x9B,
0x79, 0xF6, 0x54, 0x8C, 0x8D, 0xFA, 0x23, 0xBB, 0x39, 0x69, 0x6A, 0xAB,
0xAE, 0xC3, 0xD4, 0xFF, 0x3E, 0x0F, 0xD8, 0x2F, 0x47, 0x0B, 0xFF, 0x05,
0x7E, 0x62, 0xBD, 0x07, 0x89, 0x49, 0xD5, 0x39, 0x0D, 0x93, 0xC9, 0x9F,
0x24, 0x74, 0x31, 0x26, 0x50, 0xF4, 0xB5, 0xAC, 0x3E, 0x5C, 0x01, 0xDC,
0x60, 0x77, 0x3B, 0x5D, 0xD2, 0xBE, 0xB7, 0xCA, 0xFE, 0x7C, 0x4F, 0x4B,
0x6C, 0x5B, 0x58, 0x41, 0x3E, 0x70, 0x8D, 0x00, 0x91, 0x6A, 0x1F, 0xE0,
0x19, 0xCC, 0x59, 0x43, 0xAE, 0x67, 0xFC, 0x02, 0xDF, 0x9B, 0x90, 0xF5,
0x8C, 0x8B, 0xAD, 0x29, 0x69, 0xB9, 0x25, 0xB2, 0x81, 0xF6, 0x37, 0x6F,
0xE2, 0x7B, 0x07, 0xC6, 0xA4, 0xEA, 0x92, 0x36, 0x29, 0x77, 0x0E, 0xCB,
0xD3, 0xEF, 0xFF, 0xC5, 0x60, 0xDB, 0x35, 0x7D, 0xCB, 0x42, 0xD7, 0xDA,
0x51, 0x62, 0x99, 0xD6, 0x2B, 0xC8, 0x5F, 0x36, 0xB7, 0x0F, 0xBE, 0xF7,
0x21, 0x52, 0x6D, 0x6B, 0xFB, 0x9C, 0xAF, 0x2C, 0xEB, 0x53, 0x02, 0xCE,
0xD4, 0xD9, 0xE9, 0x7E, 0xA4, 0xFA, 0x16, 0xD8, 0x0C, 0x78, 0xD9, 0x12,
0x47, 0x57, 0xFB, 0x9B, 0x9F, 0xE3, 0x7B, 0xCF, 0x36, 0x33, 0xAF, 0x4F,
0x02, 0x27, 0xD9, 0xF9, 0xE9, 0x6C, 0xDB, 0x68, 0x8A, 0xEB, 0xED, 0x9C,
0x9F, 0x98, 0xE3, 0x77, 0x4B, 0x81, 0x41, 0xF8, 0xDE, 0x7F, 0xB2, 0xDA,
0x54, 0x18, 0xC1, 0x9E, 0x0B, 0x0D, 0x98, 0xDC, 0x7C, 0x0F, 0x66, 0x5D,
0x7F, 0x04, 0x90, 0x59, 0x7B, 0x3F, 0xCD, 0xB4, 0x25, 0xA4, 0xDA, 0x44,
0xC3, 0x73, 0xF6, 0x7D, 0x56, 0x03, 0x27, 0xE2, 0x7B, 0xCF, 0xD8, 0x7B,
0x36, 0x05, 0xC6, 0x61, 0x4A, 0xFA, 0xE6, 0x7A, 0x46, 0x02, 0xDF, 0xBB,
0x39, 0xEB, 0x19, 0x71, 0x20, 0xB3, 0x96, 0x9E, 0xB5, 0xE4, 0x06, 0xB0,
0xA2, 0x1A, 0xBA, 0xD5, 0xFB, 0x5E, 0x03, 0x52, 0xDD, 0x6E, 0xC9, 0x25,
0x5F, 0xBF, 0x7F, 0x88, 0xEF, 0x4D, 0x6D, 0x9F, 0x26, 0xAC, 0xA6, 0x9A,
0x88, 0x71, 0xF1, 0x9D, 0x1E, 0xF1, 0x9D, 0x6A, 0x6D, 0xA0, 0x5F, 0x21,
0xE4, 0xF1, 0x84, 0x23, 0x8F, 0x92, 0x61, 0x67, 0x2B, 0xF4, 0x25, 0x70,
0x0E, 0x90, 0xC0, 0x38, 0x1A, 0x00, 0x9C, 0xDA, 0x60, 0x85, 0x2C, 0x70,
0xBF, 0xFD, 0xF0, 0xAA, 0xAC, 0x70, 0x6F, 0x8A, 0x8B, 0x6C, 0x3B, 0x53,
0x3B, 0xC1, 0xF3, 0x01, 0x9F, 0xBD, 0xA7, 0xB1, 0xC2, 0x70, 0x3F, 0xC6,
0x23, 0xEA, 0x71, 0xFB, 0xFF, 0x1A, 0x8C, 0x27, 0xCD, 0x7E, 0x98, 0x1A,
0xE5, 0xD7, 0x02, 0xBF, 0x07, 0x32, 0xB5, 0xC3, 0xB7, 0xB1, 0x1A, 0x82,
0xD9, 0xA5, 0x18, 0x01, 0xFB, 0x1F, 0x4C, 0x4D, 0xF3, 0xBB, 0x30, 0xDE,
0x48, 0x27, 0x03, 0xF7, 0x01, 0xF5, 0x76, 0xD7, 0xFA, 0x70, 0x8D, 0x54,
0xB1, 0x16, 0xD6, 0xDA, 0x81, 0x56, 0x68, 0x66, 0xC8, 0xE3, 0xD0, 0x0C,
0x79, 0x08, 0xA9, 0x7A, 0x61, 0x04, 0xD0, 0xD6, 0xC0, 0xF7, 0xC0, 0xA5,
0x76, 0x77, 0x7D, 0xA6, 0x25, 0xCE, 0xEE, 0xC0, 0x28, 0xBB, 0x3B, 0x6D,
0x8A, 0xF5, 0x2D, 0x79, 0xCC, 0x01, 0x1E, 0xC1, 0x9C, 0x55, 0x90, 0x36,
0xE3, 0x7D, 0x58, 0xD6, 0xF8, 0x1E, 0x69, 0xFF, 0x3F, 0xCC, 0xF6, 0xE3,
0x96, 0x5C, 0xE4, 0x01, 0xB0, 0xBE, 0xD9, 0x6E, 0x26, 0x80, 0xBB, 0xB3,
0x09, 0x1F, 0xB8, 0x0C, 0xB8, 0x4C, 0xC0, 0xB7, 0x42, 0xAA, 0xF5, 0x6C,
0x9F, 0xFB, 0xD8, 0x71, 0x1D, 0x0D, 0xFC, 0xCA, 0xF6, 0x7D, 0x92, 0xBD,
0xE7, 0x24, 0x6D, 0x0E, 0xE1, 0x73, 0xE1, 0x10, 0x4B, 0x1E, 0xFF, 0xC1,
0x5C, 0xF3, 0x45, 0xC0, 0x79, 0xDD, 0x06, 0x53, 0x73, 0x5E, 0xD9, 0x67,
0x65, 0x48, 0x14, 0x8C, 0xB7, 0xD8, 0x89, 0x96, 0x70, 0x6F, 0xB3, 0xBF,
0x7F, 0xC6, 0xFE, 0x6E, 0x03, 0xE0, 0x81, 0x0D, 0xD6, 0x76, 0x43, 0x7E,
0x09, 0x93, 0x8E, 0xE8, 0x31, 0xDB, 0xF7, 0xA1, 0x76, 0x3D, 0x2C, 0xB2,
0x6B, 0xF1, 0x2E, 0x1B, 0xC7, 0xD6, 0xDC, 0xBC, 0xF6, 0xD6, 0xF0, 0x8A,
0x25, 0x8F, 0xBA, 0x6C, 0xF2, 0xB0, 0x6B, 0x62, 0xAC, 0x25, 0x8F, 0xD9,
0x83, 0x5D, 0x9A, 0x00, 0x00, 0x0A, 0xDD, 0x49, 0x44, 0x41, 0x54, 0x55,
0xC0, 0x70, 0x3B, 0x57, 0xC7, 0x03, 0x2F, 0xD8, 0x67, 0x8C, 0x40, 0xAA,
0xE3, 0xF2, 0xB4, 0x7E, 0xB4, 0xD5, 0x88, 0x14, 0xF0, 0xE7, 0x2A, 0xD0,
0x48, 0x75, 0x40, 0x16, 0x79, 0x4C, 0x00, 0x4E, 0xC0, 0x1C, 0x09, 0x9C,
0x05, 0x8C, 0x02, 0x9E, 0x28, 0x36, 0x79, 0x54, 0x9E, 0x09, 0x2B, 0xB7,
0x39, 0xEB, 0x25, 0x60, 0x87, 0x90, 0x77, 0x57, 0x65, 0x99, 0xB3, 0xFE,
0x1A, 0x92, 0x3C, 0x8E, 0xB7, 0xE4, 0x51, 0x13, 0xA1, 0xD7, 0x5F, 0x38,
0xF2, 0x08, 0x04, 0x0D, 0x1C, 0x83, 0xEF, 0x3D, 0x97, 0xA5, 0xEA, 0xDF,
0xA7, 0xCD, 0x0E, 0x6B, 0x2F, 0xE0, 0x62, 0x21, 0xD5, 0x4D, 0xDA, 0xF7,
0xBE, 0x46, 0xAA, 0x31, 0x56, 0x30, 0x9F, 0x29, 0xA4, 0xBA, 0x46, 0xFB,
0xDE, 0x32, 0x7B, 0x7D, 0x0F, 0x0D, 0xBF, 0xB4, 0xB7, 0xDF, 0xBD, 0x3A,
0xA3, 0xC2, 0x07, 0xC3, 0xB1, 0xF8, 0xDE, 0xF3, 0x59, 0x73, 0x3E, 0xC7,
0x92, 0x09, 0xC0, 0x37, 0x02, 0xFA, 0x6A, 0xDF, 0x9B, 0x6B, 0x35, 0x8D,
0x3B, 0xAD, 0xA6, 0xB1, 0x3B, 0x70, 0xD4, 0x46, 0xB5, 0xA3, 0xC4, 0x82,
0x46, 0x33, 0xC0, 0x95, 0x02, 0x2E, 0xD3, 0xBE, 0xB7, 0x20, 0x7B, 0x77,
0x8D, 0x54, 0xDF, 0x03, 0x7F, 0x04, 0x76, 0xA8, 0x33, 0x84, 0xF5, 0x41,
0x9E, 0xB5, 0x76, 0xB8, 0x15, 0xB0, 0xDD, 0xB2, 0xC8, 0xE3, 0xC3, 0xAC,
0x41, 0xBA, 0xD4, 0xEE, 0xA8, 0x57, 0x00, 0x07, 0xE3, 0x7B, 0x9F, 0xD9,
0x5F, 0xBD, 0x2A, 0xA4, 0x1A, 0xAB, 0xE1, 0x5D, 0xAB, 0xA5, 0x5F, 0x4E,
0xE3, 0xA1, 0x76, 0x36, 0x26, 0x0B, 0x18, 0xA0, 0x7D, 0x6F, 0x61, 0xD6,
0xCF, 0xF6, 0xB6, 0x7F, 0x7F, 0xD7, 0x5D, 0x88, 0x0B, 0x17, 0x37, 0xBE,
0xCB, 0xBF, 0x80, 0x7B, 0x3A, 0x4B, 0x15, 0x5B, 0x95, 0x67, 0xD0, 0xE6,
0x9C, 0x3D, 0x54, 0x03, 0xB7, 0x23, 0xD5, 0xFE, 0x96, 0x70, 0x00, 0xFE,
0x8E, 0xEF, 0xA9, 0xCC, 0xA4, 0x22, 0xD5, 0x45, 0x59, 0x5A, 0xD9, 0xEF,
0xF0, 0xBD, 0xDB, 0xD6, 0x7C, 0x94, 0x52, 0xDD, 0xDD, 0x60, 0x88, 0xEC,
0x68, 0xE0, 0x4C, 0xA4, 0xBA, 0x0D, 0xDF, 0x9B, 0x92, 0xE3, 0x51, 0xC3,
0xF0, 0xBD, 0x7B, 0x22, 0xAC, 0xAB, 0xAB, 0xF1, 0xBD, 0x1B, 0xB3, 0xC6,
0xF7, 0x6D, 0xCC, 0xF9, 0x25, 0x96, 0xD4, 0xFB, 0x67, 0xB9, 0xD8, 0xDE,
0x8E, 0x54, 0x4F, 0x59, 0x62, 0xD9, 0x73, 0xA9, 0xD6, 0xDB, 0x67, 0x36,
0x31, 0x31, 0xF8, 0x4B, 0x1A, 0x5E, 0x68, 0xA2, 0xF1, 0x8E, 0x46, 0xAA,
0x49, 0xC0, 0xDF, 0xAD, 0xA6, 0x73, 0x04, 0xF0, 0x50, 0x9E, 0x79, 0xDD,
0xC9, 0x12, 0xC1, 0xB6, 0xF6, 0xB9, 0x27, 0xAF, 0xD1, 0x3C, 0x0C, 0x9B,
0x1C, 0x03, 0xFC, 0xC4, 0xFE, 0xF7, 0x14, 0x7C, 0x6F, 0x4C, 0xE6, 0x77,
0x5D, 0x6A, 0xD5, 0x33, 0x2B, 0x35, 0xCF, 0x02, 0x43, 0xAC, 0xC9, 0x6A,
0x6C, 0x8E, 0x27, 0xCC, 0xB5, 0x9A, 0xCE, 0xE7, 0x58, 0x06, 0x42, 0xAA,
0x7D, 0xB3, 0x7E, 0x7F, 0x41, 0x13, 0xD3, 0xF9, 0xC3, 0x9D, 0xA4, 0x8A,
0x95, 0xC2, 0x67, 0xBC, 0xBA, 0xA2, 0xC5, 0x8C, 0xEF, 0x7D, 0x89, 0x54,
0xFD, 0x0B, 0x20, 0x91, 0xBF, 0x58, 0x12, 0x79, 0x34, 0x04, 0x79, 0x3C,
0xE9, 0xC8, 0xA3, 0xE4, 0x78, 0x31, 0x9B, 0x3C, 0x00, 0xB4, 0xEF, 0x2D,
0x46, 0xAA, 0xEB, 0xEC, 0x6E, 0xA9, 0xB3, 0x36, 0x1A, 0xE8, 0x28, 0xBB,
0x93, 0x3C, 0x19, 0xE8, 0xA9, 0x8D, 0xCD, 0x57, 0x5A, 0x61, 0x75, 0xA6,
0xDD, 0x3D, 0x2E, 0x12, 0xF0, 0x68, 0x08, 0xC3, 0xEE, 0x94, 0xB5, 0xC8,
0x23, 0x23, 0xF4, 0x1B, 0x09, 0xE4, 0xAF, 0x19, 0xF2, 0xB0, 0x9A, 0xC6,
0x6A, 0xA4, 0x7A, 0xDA, 0x12, 0x48, 0xCF, 0x85, 0x5A, 0x6F, 0x68, 0xED,
0xCA, 0xE0, 0x7B, 0xDF, 0x59, 0xA1, 0xB9, 0x85, 0x15, 0xF4, 0x19, 0x6D,
0x23, 0x3B, 0x45, 0x4D, 0xEF, 0x5C, 0x04, 0x92, 0x36, 0x3B, 0xC4, 0xBF,
0x58, 0x61, 0xB4, 0xB8, 0x29, 0x79, 0x58, 0x0C, 0xB6, 0x7F, 0xFF, 0x07,
0xA8, 0xB6, 0xE7, 0x46, 0x6B, 0x18, 0xD8, 0xEE, 0xF4, 0x2F, 0x06, 0x7E,
0xD6, 0xB5, 0x76, 0x94, 0x58, 0xB6, 0xAE, 0x7D, 0xFB, 0xEA, 0x26, 0xE4,
0x41, 0xD6, 0x8E, 0x7E, 0xD3, 0xC5, 0x5A, 0x4F, 0x44, 0xAA, 0x67, 0x2C,
0xD1, 0x7C, 0xAA, 0x7D, 0x6F, 0xF1, 0xAA, 0x70, 0x44, 0x9C, 0x6F, 0x77,
0x0C, 0xF0, 0x4D, 0x35, 0xDC, 0x59, 0x9F, 0x6D, 0x43, 0xF1, 0xBD, 0x7A,
0xA4, 0x4A, 0xD8, 0x6B, 0x84, 0x7D, 0xBF, 0xA6, 0x04, 0x32, 0x75, 0x63,
0x21, 0xEE, 0x9D, 0x17, 0xFE, 0xB9, 0xF5, 0xF6, 0x0C, 0x64, 0x0D, 0x04,
0x8C, 0xD5, 0x46, 0x83, 0xED, 0x64, 0xD7, 0xDC, 0x7B, 0x39, 0xE6, 0x3D,
0x63, 0xD6, 0x5A, 0x43, 0x20, 0x36, 0x3E, 0x67, 0x86, 0x90, 0x6A, 0x23,
0xDD, 0xE8, 0xA6, 0x0C, 0x30, 0xDF, 0x0E, 0xBD, 0xB0, 0xF3, 0x9A, 0x0B,
0xFB, 0x60, 0xDC, 0x9B, 0x33, 0xAE, 0xEA, 0x67, 0x64, 0x13, 0x84, 0xC5,
0x91, 0xF6, 0xEF, 0x6F, 0x81, 0xCF, 0xB3, 0xE7, 0x75, 0xA5, 0x5E, 0x63,
0xA2, 0x1A, 0x02, 0xEC, 0x8E, 0x54, 0x5B, 0xE7, 0x38, 0x47, 0xBD, 0x27,
0x43, 0x1E, 0x59, 0x98, 0x99, 0xF5, 0xEF, 0x71, 0x48, 0x35, 0x12, 0x73,
0x9E, 0xF2, 0x09, 0xBE, 0xF7, 0xFD, 0xEA, 0xC2, 0xE7, 0xB5, 0x0D, 0x12,
0x48, 0x71, 0x48, 0xE4, 0x21, 0xA4, 0xA2, 0x45, 0x12, 0x31, 0xEA, 0x62,
0xA1, 0xE4, 0xF1, 0x05, 0x0E, 0x41, 0xF0, 0x49, 0x5E, 0xE1, 0xDE, 0x88,
0x2D, 0xED, 0xFC, 0xBF, 0x85, 0x54, 0x6F, 0x02, 0x07, 0x00, 0x17, 0xF5,
0xAC, 0x1D, 0x55, 0xBB, 0x54, 0x6B, 0xB1, 0xDA, 0x1C, 0x96, 0x02, 0x3C,
0xA4, 0x7D, 0x6F, 0x49, 0x88, 0x67, 0xCF, 0xCC, 0xF1, 0xB3, 0xC5, 0x4D,
0x4C, 0x90, 0x4D, 0x91, 0xAD, 0x61, 0xD4, 0x64, 0xAD, 0x99, 0x83, 0xAC,
0xF9, 0xAA, 0x4F, 0x33, 0xCF, 0xCB, 0xB7, 0x9E, 0x1E, 0xCD, 0x22, 0x9C,
0x6E, 0x76, 0xB7, 0xDA, 0x94, 0x40, 0xB6, 0xB4, 0x7F, 0x0F, 0xC4, 0x9C,
0xDD, 0xE4, 0x43, 0xCF, 0x15, 0x5A, 0xD7, 0x58, 0x73, 0x5F, 0x36, 0xDE,
0x6D, 0x7A, 0xA1, 0x80, 0xFB, 0xB4, 0x39, 0x40, 0xDF, 0x07, 0x73, 0x28,
0x3F, 0xC8, 0x12, 0x52, 0x03, 0x52, 0xA5, 0x30, 0x87, 0xD6, 0xA9, 0x02,
0xE6, 0x36, 0x63, 0xDA, 0xF9, 0xA8, 0xDE, 0xF7, 0x1A, 0x72, 0x08, 0x9C,
0xA9, 0xF5, 0x46, 0xA3, 0x5A, 0xCF, 0xBE, 0x73, 0x53, 0x7C, 0x30, 0x2F,
0xDA, 0x41, 0xEF, 0x1C, 0xED, 0x7B, 0x4B, 0x9B, 0x0C, 0xFC, 0xEA, 0xD5,
0x8D, 0x04, 0x92, 0x6B, 0x5E, 0x17, 0x35, 0x91, 0x15, 0x99, 0x79, 0xDD,
0x01, 0x33, 0x4E, 0x87, 0x92, 0xDF, 0xCC, 0x9F, 0x4F, 0x76, 0xDE, 0xD5,
0xE4, 0x9E, 0x5C, 0x01, 0xD2, 0x99, 0x79, 0xDD, 0x3C, 0xC7, 0x9C, 0x37,
0xC5, 0xA6, 0xD6, 0xA4, 0xDA, 0xEC, 0xBC, 0xD6, 0xC0, 0x0B, 0x75, 0x66,
0xC3, 0x35, 0xD4, 0x6A, 0x99, 0x7B, 0xAF, 0xD9, 0x6B, 0x48, 0xF5, 0x31,
0x30, 0x02, 0xDF, 0x1B, 0xD9, 0xF1, 0x08, 0xA4, 0x91, 0x44, 0x32, 0xE6,
0xAC, 0xED, 0x23, 0x92, 0x88, 0xC6, 0xF7, 0x1E, 0x6B, 0x86, 0x3C, 0x9E,
0x72, 0xE4, 0xD1, 0x6A, 0xE8, 0x91, 0xE7, 0xE7, 0x1B, 0x65, 0xFD, 0x7B,
0x59, 0x93, 0x8F, 0xF2, 0x00, 0x60, 0xAF, 0xF9, 0x5A, 0xF7, 0xB3, 0x02,
0x77, 0x07, 0xB3, 0x91, 0x27, 0xAC, 0xA9, 0xA3, 0xA1, 0x85, 0xDF, 0x07,
0xDB, 0xA9, 0x49, 0xD5, 0xDB, 0x6A, 0x00, 0x5D, 0xAD, 0x70, 0xFA, 0x9B,
0xDD, 0xA1, 0x02, 0x6C, 0x65, 0x35, 0x83, 0xE6, 0x10, 0xB3, 0xC2, 0x63,
0x3B, 0xFB, 0x3E, 0x8F, 0x22, 0xD5, 0x8F, 0x9B, 0xEC, 0x2C, 0x97, 0x02,
0x1B, 0x63, 0x3C, 0xD4, 0xC6, 0x35, 0xD7, 0xE7, 0x18, 0x34, 0xA4, 0xD7,
0x25, 0x8B, 0xBA, 0xA6, 0x92, 0x58, 0xFB, 0xDE, 0xFC, 0x2A, 0xA9, 0xF6,
0x6F, 0x80, 0xA3, 0x30, 0x36, 0xF2, 0x3E, 0x18, 0x0F, 0xB2, 0x4D, 0x30,
0xE7, 0x2B, 0xFF, 0x42, 0xAA, 0xBD, 0x72, 0xEC, 0x70, 0x83, 0x22, 0x33,
0x6F, 0xBD, 0xF2, 0x0C, 0x7E, 0x37, 0x1A, 0x0F, 0xE1, 0x17, 0xE5, 0xB8,
0x64, 0x55, 0xC4, 0xE7, 0xD6, 0x15, 0x63, 0x5E, 0xAD, 0x67, 0xD4, 0x73,
0x56, 0xF0, 0x2F, 0xC0, 0x9C, 0xF1, 0x7C, 0x65, 0x35, 0x8F, 0x2A, 0xCC,
0xF9, 0x5C, 0xAC, 0x85, 0x79, 0xFD, 0x0E, 0x58, 0x62, 0xCD, 0x8B, 0x57,
0x21, 0xD5, 0x24, 0x7C, 0x6F, 0x7C, 0xD6, 0x35, 0x99, 0x0D, 0xCF, 0x42,
0xA0, 0xA5, 0x92, 0xDD, 0xDF, 0xE4, 0xF8, 0xD9, 0x3A, 0xD6, 0xA8, 0x3A,
0xDF, 0x4B, 0xF7, 0xA8, 0x1D, 0x75, 0xE2, 0x42, 0xAD, 0xEF, 0xB5, 0x9A,
0x5D, 0x5F, 0xFB, 0x0E, 0xBD, 0x31, 0x66, 0xD4, 0xC7, 0x91, 0x6A, 0x65,
0x0E, 0x6D, 0xA8, 0x03, 0x10, 0x88, 0x21, 0x91, 0xD9, 0x59, 0x9A, 0x48,
0x14, 0x12, 0x79, 0xD8, 0x6A, 0x22, 0x8F, 0x35, 0x11, 0x04, 0xC7, 0x3A,
0xCD, 0xA3, 0xD5, 0x31, 0x44, 0x48, 0xB5, 0x49, 0xB6, 0xA9, 0xC8, 0xE2,
0xF4, 0xAC, 0x7F, 0xBF, 0x9D, 0xF5, 0x45, 0x8E, 0x4E, 0xC3, 0xCD, 0x76,
0xE7, 0x36, 0xCC, 0x0A, 0x5C, 0x30, 0xF6, 0xF7, 0x99, 0x65, 0x7A, 0x87,
0xC1, 0x34, 0x7A, 0x0A, 0x0D, 0xC2, 0xF7, 0xA6, 0x65, 0xAD, 0xA9, 0x9F,
0x04, 0x20, 0x90, 0xD7, 0x05, 0x0C, 0xB1, 0xA6, 0xBA, 0x31, 0xC0, 0x86,
0xC0, 0x18, 0x21, 0xD5, 0x81, 0x59, 0x3B, 0xE9, 0x49, 0x98, 0xC3, 0xE1,
0x5E, 0x02, 0x6E, 0xD1, 0xBE, 0xB7, 0x96, 0x65, 0xA7, 0x5A, 0xAA, 0xAA,
0x2E, 0x42, 0xA4, 0x97, 0x9E, 0x33, 0x54, 0xD7, 0x87, 0x61, 0x50, 0xDF,
0xAB, 0xC3, 0xD8, 0xD6, 0xC7, 0xC2, 0x1A, 0xEF, 0xAA, 0xA3, 0x30, 0x07,
0xCB, 0x9D, 0xED, 0xBB, 0x45, 0x25, 0x90, 0x37, 0x81, 0x1F, 0x03, 0x7B,
0x5B, 0x22, 0x5A, 0x6B, 0x87, 0xAD, 0xCD, 0x1C, 0xC7, 0x9A, 0xCE, 0x71,
0xA5, 0xA0, 0xC1, 0x10, 0x6A, 0x46, 0x6B, 0xB8, 0x10, 0xDF, 0x7B, 0x72,
0x0D, 0x21, 0x4B, 0xD5, 0x55, 0x1B, 0x8F, 0xAE, 0xE6, 0x30, 0x1B, 0xE3,
0x94, 0x50, 0x65, 0xC7, 0xA2, 0x3B, 0xF0, 0x18, 0x52, 0xED, 0x97, 0x75,
0xA6, 0x32, 0x09, 0x73, 0x60, 0xBE, 0x21, 0x30, 0x16, 0xDF, 0x9B, 0x94,
0x93, 0xC8, 0x7C, 0x2F, 0xCC, 0xB4, 0xB2, 0xD0, 0x68, 0x6E, 0xAF, 0xD8,
0x3F, 0x99, 0xB5, 0xB8, 0x27, 0xC6, 0x39, 0xA2, 0x27, 0xC6, 0x5D, 0xB8,
0x83, 0x12, 0x48, 0xF1, 0x48, 0x44, 0xE3, 0x7B, 0x8F, 0xDB, 0xC1, 0x1D,
0x6C, 0xC9, 0xA3, 0x53, 0x84, 0xDE, 0xFC, 0x17, 0x13, 0x61, 0xEE, 0xC8,
0x23, 0x3C, 0x7A, 0x6A, 0x98, 0x88, 0x54, 0xE7, 0x75, 0x11, 0x4C, 0x5E,
0xA5, 0xE9, 0xA1, 0xCD, 0x19, 0x84, 0xBF, 0x46, 0x08, 0x65, 0xC5, 0x22,
0xA4, 0x8D, 0x4B, 0xEF, 0xFD, 0x98, 0x8F, 0xF7, 0xB8, 0x26, 0x9A, 0x49,
0xB9, 0x90, 0xBD, 0xA3, 0xDD, 0x0A, 0x1B, 0x00, 0x2B, 0xA4, 0xDA, 0x40,
0xC3, 0x9F, 0x5A, 0xBA, 0x59, 0xC0, 0x91, 0xDA, 0xF7, 0x16, 0x01, 0xCF,
0xD8, 0xB3, 0x9F, 0x6B, 0x80, 0x3D, 0xB4, 0x39, 0xE3, 0x39, 0x39, 0xEB,
0xFD, 0x8E, 0x07, 0x36, 0xD1, 0x46, 0x33, 0x18, 0x56, 0x03, 0x6F, 0x54,
0x09, 0xF4, 0x4A, 0xCD, 0x5E, 0xF5, 0x30, 0x62, 0xA9, 0xD6, 0xAF, 0x63,
0x3C, 0x84, 0x82, 0xC1, 0x1C, 0xB6, 0x5E, 0x01, 0xDC, 0x13, 0x83, 0xD7,
0xD3, 0xBE, 0x57, 0xB7, 0xEC, 0x9C, 0xA1, 0xDA, 0xBA, 0x58, 0xE7, 0xD2,
0xFE, 0xC2, 0xE2, 0x7E, 0x8C, 0x2B, 0x74, 0x27, 0xE0, 0x59, 0xA4, 0xF2,
0xAB, 0xE1, 0xC5, 0x06, 0xE8, 0xA2, 0x4D, 0x4C, 0xCA, 0x70, 0x7B, 0xDD,
0xB4, 0x18, 0x4C, 0x48, 0x57, 0xDE, 0xDA, 0x6C, 0x3A, 0xAF, 0xC6, 0x44,
0x64, 0x62, 0x67, 0x6E, 0xA4, 0xE5, 0x74, 0x2D, 0x67, 0xAF, 0xD1, 0xDE,
0xA4, 0xFA, 0x25, 0x26, 0xAE, 0xA5, 0x07, 0x30, 0xD6, 0x6E, 0x0E, 0x96,
0x0B, 0x78, 0x58, 0x1B, 0x6F, 0xB6, 0x0D, 0x31, 0xE7, 0x15, 0xC3, 0x62,
0x30, 0xBE, 0x8B, 0x10, 0xAB, 0x97, 0x6B, 0xBD, 0x0D, 0xF0, 0x87, 0x06,
0xD8, 0xB4, 0x46, 0xAA, 0xA1, 0x75, 0x39, 0xCC, 0x80, 0x39, 0xD7, 0x93,
0x71, 0x2A, 0x79, 0x02, 0xA8, 0x15, 0xF0, 0x42, 0xD6, 0x26, 0x64, 0x59,
0x91, 0xE6, 0x35, 0xAF, 0xBA, 0xD5, 0xB6, 0x60, 0x0E, 0x94, 0xFA, 0x93,
0xDB, 0x96, 0x1D, 0x84, 0x44, 0x1E, 0x41, 0xAA, 0x53, 0x2D, 0x79, 0x8C,
0x21, 0xBF, 0x4F, 0x7B, 0x4B, 0xE4, 0xD1, 0x1F, 0xDF, 0x9B, 0xE5, 0xB8,
0x20, 0x12, 0x56, 0x60, 0xDC, 0x65, 0xDF, 0x59, 0xA9, 0x59, 0xA2, 0x8D,
0x57, 0xC9, 0x55, 0x46, 0xAE, 0x32, 0x17, 0xE3, 0x7A, 0xD8, 0x54, 0xE0,
0x3E, 0x68, 0xEF, 0x8B, 0xD9, 0x3F, 0x1F, 0x75, 0x15, 0xE2, 0xA5, 0x32,
0xBE, 0xC3, 0xB3, 0x59, 0x26, 0x98, 0x09, 0x48, 0x35, 0x01, 0xA9, 0x46,
0xEB, 0x4C, 0x00, 0x69, 0xCB, 0x04, 0xB2, 0x46, 0x60, 0x77, 0x36, 0x04,
0x90, 0x31, 0x51, 0x9D, 0x64, 0x83, 0xE3, 0xB0, 0x31, 0x24, 0x57, 0x5B,
0xF3, 0xC9, 0xDE, 0xC0, 0x6B, 0x75, 0xB0, 0x74, 0xA5, 0x66, 0x09, 0xC6,
0x33, 0xEC, 0x70, 0xE0, 0x0A, 0xA4, 0xDA, 0x3A, 0x44, 0xBF, 0xFF, 0x68,
0x49, 0xE9, 0xA5, 0x34, 0x2C, 0x42, 0xAA, 0x99, 0x36, 0x06, 0xE3, 0x05,
0x3B, 0xFE, 0x5F, 0x8B, 0xDC, 0x9E, 0x3F, 0x41, 0xBF, 0xCF, 0xCF, 0x30,
0xE7, 0x53, 0x69, 0xAB, 0x3D, 0xBD, 0x50, 0x0F, 0xCB, 0xB4, 0x19, 0xAB,
0xFB, 0x30, 0xF1, 0x37, 0xF3, 0x81, 0x93, 0xD2, 0x46, 0x13, 0xAA, 0x28,
0x74, 0x32, 0x0E, 0x0F, 0x99, 0xF3, 0xA6, 0xE1, 0x48, 0xF5, 0x32, 0x52,
0x3D, 0x59, 0x67, 0x34, 0xB2, 0x8B, 0x03, 0x98, 0xC2, 0x96, 0x67, 0x8D,
0xC5, 0xB3, 0x34, 0x6E, 0x26, 0xF6, 0xD2, 0x66, 0x0D, 0xA3, 0x7D, 0xEF,
0x7B, 0x4C, 0xB0, 0xEA, 0x2A, 0xCC, 0x39, 0xC8, 0xE8, 0x34, 0x2C, 0x59,
0xAE, 0xF5, 0x22, 0x6B, 0x0E, 0x3D, 0x0F, 0x38, 0xB6, 0x0E, 0x7E, 0x16,
0xB4, 0xDF, 0x1A, 0x7E, 0x8D, 0xB9, 0x7E, 0x8C, 0x86, 0x05, 0x48, 0xF5,
0x5F, 0xA4, 0xFA, 0xAF, 0xDD, 0xD8, 0xF4, 0xB4, 0xCF, 0x2A, 0x7A, 0x49,
0xF0, 0x58, 0x9B, 0x14, 0x3F, 0x8D, 0x24, 0x12, 0x45, 0x80, 0x1B, 0x12,
0x31, 0xE4, 0x11, 0x25, 0xF1, 0xD9, 0x6C, 0x47, 0x1E, 0x05, 0x63, 0xB8,
0x15, 0x26, 0xAB, 0x30, 0xF1, 0x0A, 0x02, 0x63, 0xC3, 0x36, 0xBE, 0xF1,
0xBE, 0xF7, 0xE9, 0x3A, 0x1F, 0x88, 0xEF, 0xCD, 0xB1, 0xDA, 0x62, 0x06,
0x77, 0x2F, 0x2B, 0x67, 0x39, 0x60, 0x53, 0x49, 0xF2, 0x08, 0xCC, 0x39,
0xC6, 0x7A, 0xD6, 0xEC, 0x73, 0xBC, 0x15, 0x20, 0xE7, 0x86, 0x69, 0x6A,
0x95, 0xEF, 0xA5, 0x85, 0xD9, 0x9D, 0x67, 0x9C, 0x0B, 0x6E, 0xB6, 0x11,
0xC5, 0xE0, 0x7B, 0x37, 0x60, 0xBC, 0x96, 0x26, 0x59, 0x22, 0xE9, 0x62,
0xC7, 0x2C, 0x8D, 0x71, 0x7B, 0x1E, 0x10, 0x32, 0xDB, 0xC1, 0x3D, 0x18,
0x1B, 0xFF, 0x4A, 0xDB, 0xEF, 0xED, 0x30, 0x1E, 0x64, 0xAB, 0xED, 0xF8,
0xF7, 0x6B, 0x6A, 0x2A, 0x8B, 0x30, 0x36, 0xB5, 0xD6, 0x8C, 0xF3, 0xA6,
0xED, 0x73, 0x67, 0xFB, 0xDD, 0xAD, 0xC6, 0x1C, 0xF4, 0xFE, 0xA8, 0x52,
0x33, 0xD6, 0x5A, 0x6F, 0xA5, 0x21, 0x96, 0x50, 0xC1, 0x9C, 0x0B, 0x9D,
0x64, 0x85, 0xF0, 0x25, 0x18, 0xCF, 0xA9, 0xC0, 0x58, 0x5F, 0x88, 0xEB,
0xAC, 0x16, 0x02, 0x70, 0x0A, 0x52, 0x0D, 0xB3, 0x63, 0x34, 0x1E, 0x73,
0xAE, 0xF7, 0x1C, 0xC6, 0xD5, 0xB7, 0x06, 0x63, 0x9A, 0xD5, 0x98, 0x33,
0xAF, 0x93, 0x9B, 0x9C, 0x9B, 0xB4, 0x84, 0xB1, 0x98, 0xB8, 0x95, 0x05,
0xD6, 0xB2, 0xB4, 0x35, 0x8D, 0xC1, 0xA7, 0x29, 0xBB, 0x4E, 0xDE, 0x2D,
0xF6, 0x78, 0x55, 0xB7, 0x59, 0x11, 0xB4, 0xB6, 0x39, 0x6B, 0xBB, 0x08,
0x24, 0x12, 0x25, 0x73, 0xA8, 0x23, 0x8F, 0x02, 0x20, 0x1A, 0x17, 0xF4,
0x32, 0xED, 0x7B, 0xCB, 0x85, 0x54, 0x7F, 0xB0, 0x79, 0x95, 0x10, 0x30,
0x53, 0xFB, 0xDE, 0xE2, 0x16, 0x9A, 0xC8, 0x9C, 0x99, 0xCC, 0x13, 0x30,
0x52, 0x87, 0x7B, 0xF6, 0x81, 0x19, 0x21, 0xA6, 0xD7, 0xFD, 0x08, 0xA6,
0x35, 0x98, 0x20, 0x34, 0x80, 0x25, 0x7A, 0xDD, 0x7B, 0xEF, 0xC7, 0x78,
0x4D, 0xD1, 0x09, 0xE6, 0xAD, 0x6A, 0x5C, 0x83, 0x6F, 0x02, 0x7D, 0xAC,
0x06, 0xB0, 0x29, 0xB0, 0xA0, 0x06, 0x66, 0xC5, 0x40, 0xAF, 0x36, 0xF1,
0x02, 0x08, 0x58, 0x9C, 0x6E, 0x6C, 0xE7, 0x0E, 0xAC, 0x1B, 0xF2, 0x46,
0x42, 0xAC, 0x9E, 0xB7, 0x36, 0x41, 0x2E, 0x16, 0x52, 0x65, 0x47, 0x60,
0xAF, 0xD2, 0x8D, 0xCF, 0x19, 0x0F, 0x8C, 0xB7, 0x91, 0xCD, 0xBD, 0x6D,
0x5B, 0xB3, 0x73, 0x09, 0x7A, 0x61, 0xDA, 0x7F, 0x12, 0xA0, 0xB3, 0x60,
0xC1, 0xCA, 0x75, 0xBF, 0x9B, 0x17, 0x80, 0x17, 0x6C, 0x24, 0xFD, 0xD6,
0x18, 0xC7, 0x85, 0x65, 0x02, 0xBE, 0xC8, 0xC4, 0xD8, 0x04, 0xF8, 0x78,
0xDE, 0x4B, 0xDB, 0xF1, 0xCA, 0x7E, 0xBF, 0x26, 0xCF, 0x79, 0x11, 0x38,
0x50, 0x48, 0xD5, 0xCB, 0xBA, 0xC2, 0xD6, 0x09, 0x98, 0x91, 0xEF, 0x19,
0x02, 0xF6, 0xB0, 0x1B, 0xDA, 0x55, 0x21, 0xE7, 0xF5, 0x57, 0x19, 0xCD,
0x40, 0xE7, 0x20, 0x03, 0x21, 0xD5, 0x76, 0x76, 0x83, 0xB2, 0x52, 0xAF,
0xBB, 0x7B, 0x4E, 0xE9, 0xC6, 0xF7, 0x58, 0x94, 0x6E, 0xEC, 0xFB, 0x97,
0xC0, 0x20, 0xA4, 0xFA, 0x81, 0x15, 0xC2, 0x2B, 0x62, 0x30, 0x3D, 0xED,
0x7B, 0xAB, 0x84, 0x54, 0x8F, 0xDB, 0x7E, 0x2E, 0xD7, 0x8D, 0xED, 0xBC,
0x98, 0x69, 0x27, 0x06, 0x0B, 0xB3, 0x6D, 0x4E, 0xCB, 0xCF, 0x19, 0xAA,
0x63, 0x52, 0x9D, 0xA4, 0x8D, 0xB9, 0x0A, 0xA0, 0x21, 0x6B, 0x5E, 0xDF,
0xC7, 0x9C, 0x07, 0x76, 0xD3, 0x46, 0x86, 0x75, 0x02, 0xBE, 0xCD, 0xA4,
0x3A, 0x69, 0x32, 0xE6, 0x6F, 0xA5, 0x73, 0xF5, 0xB5, 0xB1, 0xAD, 0x8F,
0x80, 0xD3, 0xAA, 0xA5, 0xAA, 0xAA, 0x37, 0xA6, 0xB7, 0x5E, 0x76, 0xCC,
0x67, 0x37, 0x89, 0x53, 0x2A, 0xF6, 0x37, 0xDD, 0xC6, 0x21, 0xD5, 0x36,
0x11, 0x49, 0x24, 0x2A, 0x79, 0xCC, 0xC4, 0xA1, 0xB5, 0xE6, 0xF6, 0x38,
0x2B, 0xDC, 0x5E, 0xD1, 0x10, 0xB7, 0x66, 0x9E, 0xF5, 0x81, 0x1B, 0xF1,
0xBD, 0x2B, 0xDD, 0x00, 0x39, 0x38, 0x94, 0x7D, 0x53, 0xD8, 0x2E, 0x04,
0xCD, 0xB6, 0x96, 0x44, 0xB6, 0x2D, 0xD1, 0x13, 0xBE, 0xB4, 0xE4, 0x31,
0xC3, 0x2D, 0x99, 0x56, 0x5A, 0x98, 0x52, 0xAD, 0xAF, 0x61, 0x2A, 0x66,
0x37, 0x55, 0x9F, 0xA5, 0x2D, 0xCF, 0x14, 0xB0, 0x4F, 0x00, 0x6D, 0xC5,
0xC1, 0xC1, 0xA1, 0xC4, 0x88, 0xB5, 0x8B, 0xB7, 0x30, 0x9E, 0x50, 0xFD,
0x09, 0x9E, 0x37, 0xC7, 0x91, 0x47, 0xE5, 0xA3, 0x1A, 0x93, 0x5E, 0x63,
0x99, 0xFD, 0x77, 0x03, 0xC6, 0x2E, 0x3D, 0xD0, 0x91, 0x87, 0x83, 0x83,
0xD3, 0x40, 0xDA, 0x82, 0x26, 0xE2, 0xC8, 0xA3, 0xCC, 0xB0, 0x69, 0xC1,
0x7B, 0x09, 0x58, 0x14, 0xD4, 0x46, 0xEF, 0xE0, 0xE0, 0xE0, 0x08, 0x24,
0x2A, 0x89, 0x6C, 0x67, 0x49, 0x64, 0x9B, 0x22, 0x90, 0xC7, 0x00, 0x57,
0x24, 0xC7, 0xC1, 0xC1, 0xC1, 0xA1, 0xA3, 0x10, 0x48, 0x71, 0x48, 0xE4,
0x2B, 0xAB, 0x79, 0x38, 0xF2, 0x70, 0x70, 0x70, 0x70, 0xE8, 0x50, 0x04,
0x52, 0x18, 0x89, 0x38, 0xF2, 0x70, 0x70, 0x70, 0x70, 0xE8, 0xD0, 0x04,
0x12, 0x8D, 0x44, 0x1C, 0x79, 0x38, 0x38, 0x38, 0x38, 0x38, 0x02, 0x59,
0x43, 0x22, 0xDB, 0x5B, 0x12, 0x69, 0x29, 0xDD, 0xC3, 0xD7, 0x96, 0x3C,
0xA6, 0xB9, 0x65, 0xE1, 0xE0, 0xE0, 0xE0, 0xE0, 0x08, 0x24, 0x28, 0x89,
0x38, 0xF2, 0x70, 0x70, 0x70, 0x70, 0x70, 0x04, 0x12, 0x9A, 0x44, 0x1C,
0x79, 0x38, 0x38, 0x38, 0x38, 0x44, 0x40, 0xAC, 0xC3, 0xBC, 0xA9, 0x49,
0x41, 0x32, 0x00, 0xE3, 0x9E, 0x9B, 0x4D, 0x1E, 0x03, 0x1C, 0x79, 0x38,
0x38, 0x38, 0x38, 0x38, 0x0D, 0x24, 0x88, 0x26, 0xB2, 0x83, 0xD5, 0x44,
0x62, 0x96, 0x3C, 0x3E, 0x77, 0xCB, 0xC0, 0xC1, 0xC1, 0xC1, 0xC1, 0x21,
0x28, 0x89, 0xEC, 0x88, 0x54, 0x3B, 0xBB, 0x81, 0x70, 0x70, 0x70, 0x70,
0x88, 0x8E, 0xFF, 0x07, 0x02, 0x8E, 0xE9, 0xCF, 0xD4, 0x3B, 0x89, 0x6F,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
};
const unsigned int logo_web_png_len = 11136;

View File

@ -1,7 +0,0 @@
#pragma once
#include <Arduino.h>
#include <pgmspace.h>
#include <stddef.h>
extern const uint8_t logo_web_png[];
extern const size_t logo_web_png_len;