Merge branch 'framework' into 'master'

Framework

See merge request lukas/pumpensteuerung!5
This commit is contained in:
Lukas Heiligenbrunner 2020-12-08 15:12:15 +00:00
commit 1547854edf
208 changed files with 25332 additions and 335 deletions

16
.clang-format Normal file
View File

@ -0,0 +1,16 @@
Language: Cpp
BasedOnStyle: Google
ColumnLimit: 120
AllowShortBlocksOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
BinPackArguments: false
BinPackParameters: false
BreakConstructorInitializers: AfterColon
AllowAllParametersOfDeclarationOnNextLine: false
ConstructorInitializerAllOnOneLineOrOnePerLine: true
ExperimentalAutoDetectBinPacking: false
KeepEmptyLinesAtTheStartOfBlocks: false
DerivePointerAlignment: false
SortIncludes: false

9
.gitignore vendored
View File

@ -1,2 +1,9 @@
.pio
CMakeListsPrivate.txt
.clang_complete
.gcc-flags.json
*Thumbs.db
/data/www
/lib/framework/WWWData.h
/interface/build
/interface/node_modules
.vscode

19
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,19 @@
image: nikolaik/python-nodejs:python3.9-nodejs15-slim
stages:
- build
cache:
paths:
- "~/.platformio"
before_script:
- "pip install -U platformio"
- "platformio update"
build:
stage: build
script: "platformio run -e esp12e"
artifacts:
paths:
- .pio/build/esp12e/*.bin

2
.idea/.gitignore vendored
View File

@ -1,2 +0,0 @@
# Default ignored files
/workspace.xml

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module classpath="CMake" type="CPP_MODULE" version="4" />

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CMakeWorkspace" PROJECT_DIR="$PROJECT_DIR$" />
<component name="CidrRootsConfiguration">
<sourceRoots>
<file path="$PROJECT_DIR$/src" />
</sourceRoots>
<libraryRoots>
<file path="$PROJECT_DIR$/lib" />
<file path="$PROJECT_DIR$/.pio/libdeps" />
</libraryRoots>
<excludeRoots>
<file path="$PROJECT_DIR$/.pio" />
</excludeRoots>
</component>
</project>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/clion.iml" filepath="$PROJECT_DIR$/.idea/clion.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/platformio.iml" filepath="$PROJECT_DIR$/.idea/platformio.iml" />
</modules>
</component>
</project>

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module classpath="CMake" type="CPP_MODULE" version="4" />

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SerialMonitorSettings" PortName="/dev/ttyUSB1" BaudRate="9600" LineEndingsIndex="0" ShowStatusWidget="false" />
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="-f -c clion init --ide clion" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="NEVER" />
<option name="fileExtension" value="ini" />
<option name="immediateSync" value="false" />
<option name="name" value="Monitor platformio.ini" />
<option name="output" value="" />
<option name="outputFilters">
<array>
<FilterInfo>
<option name="description" value="" />
<option name="name" value="PIO Conf" />
<option name="regExp" value="$FILE_PATH$:^platformio" />
</FilterInfo>
</array>
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="/usr/local/bin/platformio" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="false" />
<option name="workingDir" value="$PROJECT_DIR$" />
<envs />
</TaskOptions>
</component>
</project>

19
.travis.yml Normal file
View File

@ -0,0 +1,19 @@
language: python
python:
- "3.8"
before_install:
- nvm install 10.15.3
- nvm use 10.15.3
sudo: false
cache:
directories:
- "~/.platformio"
install:
- pip install -U platformio
- platformio update
script:
- platformio run -e esp12e -e node32s

289
CMakeListsPrivate.txt Normal file
View File

@ -0,0 +1,289 @@
# !!! WARNING !!! AUTO-GENERATED FILE, PLEASE DO NOT MODIFY IT AND USE
# https://docs.platformio.org/page/projectconf/section_env_build.html#build-flags
#
# If you need to override existing CMake configuration or add extra,
# please create `CMakeListsUser.txt` in the root of project.
# The `CMakeListsUser.txt` will not be overwritten by PlatformIO.
set(CMAKE_CONFIGURATION_TYPES "esp12e;node32s;" CACHE STRING "Build Types reflect PlatformIO Environments" FORCE)
SET(CMAKE_C_COMPILER "$ENV{HOME}/.platformio/packages/toolchain-xtensa/bin/xtensa-lx106-elf-gcc")
SET(CMAKE_CXX_COMPILER "$ENV{HOME}/.platformio/packages/toolchain-xtensa/bin/xtensa-lx106-elf-g++")
SET(CMAKE_CXX_FLAGS "-fno-rtti -std=c++11 -Os -mlongcalls -mtext-section-literals -falign-functions=4 -U__STRICT_ANSI__ -ffunction-sections -fdata-sections -fno-exceptions -Wall")
SET(CMAKE_C_FLAGS "-std=gnu99 -Wpointer-arith -Wno-implicit-function-declaration -Wl,-EL -fno-inline-functions -nostdlib -Os -mlongcalls -mtext-section-literals -falign-functions=4 -U__STRICT_ANSI__ -ffunction-sections -fdata-sections -fno-exceptions -Wall")
SET(CMAKE_C_STANDARD 99)
set(CMAKE_CXX_STANDARD 11)
if (CMAKE_BUILD_TYPE MATCHES "esp12e")
add_definitions(-D'PLATFORMIO=50003')
add_definitions(-D'ESP8266')
add_definitions(-D'ARDUINO_ARCH_ESP8266')
add_definitions(-D'ARDUINO_ESP8266_ESP12')
add_definitions(-D'FACTORY_WIFI_SSID=\"\"')
add_definitions(-D'FACTORY_WIFI_PASSWORD=\"\"')
add_definitions(-D'FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED')
add_definitions(-D'FACTORY_AP_SSID=\"ESP8266-React\"')
add_definitions(-D'FACTORY_AP_PASSWORD=\"esp-react\"')
add_definitions(-D'FACTORY_AP_LOCAL_IP=\"192.168.4.1\"')
add_definitions(-D'FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"')
add_definitions(-D'FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"')
add_definitions(-D'FACTORY_ADMIN_USERNAME=\"admin\"')
add_definitions(-D'FACTORY_ADMIN_PASSWORD=\"admin\"')
add_definitions(-D'FACTORY_GUEST_USERNAME=\"guest\"')
add_definitions(-D'FACTORY_GUEST_PASSWORD=\"guest\"')
add_definitions(-D'FACTORY_NTP_ENABLED=true')
add_definitions(-D'FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/London\"')
add_definitions(-D'FACTORY_NTP_TIME_ZONE_FORMAT=\"GMT0BST,M3.5.0/1,M10.5.0\"')
add_definitions(-D'FACTORY_NTP_SERVER=\"time.google.com\"')
add_definitions(-D'FACTORY_OTA_PORT=8266')
add_definitions(-D'FACTORY_OTA_PASSWORD=\"esp-react\"')
add_definitions(-D'FACTORY_OTA_ENABLED=true')
add_definitions(-D'FACTORY_MQTT_ENABLED=false')
add_definitions(-D'FACTORY_MQTT_HOST=\"test.mosquitto.org\"')
add_definitions(-D'FACTORY_MQTT_PORT=1883')
add_definitions(-D'FACTORY_MQTT_USERNAME=\"\"')
add_definitions(-D'FACTORY_MQTT_PASSWORD=\"\"')
add_definitions(-D'FACTORY_MQTT_KEEP_ALIVE=60')
add_definitions(-D'FACTORY_MQTT_CLEAN_SESSION=true')
add_definitions(-D'FACTORY_MQTT_MAX_TOPIC_LENGTH=128')
add_definitions(-D'FT_PROJECT=1')
add_definitions(-D'FT_SECURITY=0')
add_definitions(-D'FT_MQTT=0')
add_definitions(-D'FT_NTP=0')
add_definitions(-D'FT_OTA=1')
add_definitions(-D'FT_UPLOAD_FIRMWARE=1')
add_definitions(-D'NO_GLOBAL_ARDUINOOTA')
add_definitions(-D'ENABLE_CORS')
add_definitions(-D'CORS_ORIGIN=\"http://localhost:3000\"')
add_definitions(-D'PROGMEM_WWW')
add_definitions(-D'F_CPU=160000000L')
add_definitions(-D'__ets__')
add_definitions(-D'ICACHE_FLASH')
add_definitions(-D'ARDUINO=10805')
add_definitions(-D'ARDUINO_BOARD=\"PLATFORMIO_ESP12E\"')
add_definitions(-D'FLASHMODE_DIO')
add_definitions(-D'LWIP_OPEN_SRC')
add_definitions(-D'NONOSDK22x_190703=1')
add_definitions(-D'TCP_MSS=536')
add_definitions(-D'LWIP_FEATURES=1')
add_definitions(-D'LWIP_IPV6=0')
add_definitions(-D'VTABLES_IN_FLASH')
include_directories("${CMAKE_CURRENT_LIST_DIR}/include")
include_directories("${CMAKE_CURRENT_LIST_DIR}/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/lib/framework")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ArduinoOTA")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266mDNS/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/DNSServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/LittleFS/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/DHT/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/Ticker/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/AsyncMqttClient/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/ESP Async WebServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/Hash/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/ESPAsyncTCP/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266WiFi/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/ArduinoJson/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/tools/sdk/include")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/tools/sdk/libc/xtensa-lx106-elf/include")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/cores/esp8266")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/tools/sdk/lwip2/include")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/variants/nodemcu")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/ESPAsyncTCP@1.2.0/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/EEPROM")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266AVRISP/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266HTTPClient/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266HTTPUpdateServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266LLMNR")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266NetBIOS")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266SSDP")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266SdFat/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266WebServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266WiFiMesh/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/ESP8266httpUpdate/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/Ethernet/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/GDBStub/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/SD/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/SDFS/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/SPI")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/SPISlave/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/Servo/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/SoftwareSerial/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/TFT_Touch_Shield_V2")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/Wire")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif8266/libraries/esp8266/src")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa/xtensa-lx106-elf/include/c++/4.8.2")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa/xtensa-lx106-elf/include/c++/4.8.2/xtensa-lx106-elf")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa/lib/gcc/xtensa-lx106-elf/4.8.2/include-fixed")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa/lib/gcc/xtensa-lx106-elf/4.8.2/include")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa/xtensa-lx106-elf/include")
include_directories("$ENV{HOME}/.platformio/packages/tool-unity")
FILE(GLOB_RECURSE EXTRA_LIB_SOURCES
${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/esp12e/*.*
)
endif()
if (CMAKE_BUILD_TYPE MATCHES "node32s")
add_definitions(-D'PLATFORMIO=50003')
add_definitions(-D'ARDUINO_Node32s')
add_definitions(-D'FACTORY_WIFI_SSID=\"\"')
add_definitions(-D'FACTORY_WIFI_PASSWORD=\"\"')
add_definitions(-D'FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED')
add_definitions(-D'FACTORY_AP_SSID=\"ESP8266-React\"')
add_definitions(-D'FACTORY_AP_PASSWORD=\"esp-react\"')
add_definitions(-D'FACTORY_AP_LOCAL_IP=\"192.168.4.1\"')
add_definitions(-D'FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"')
add_definitions(-D'FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"')
add_definitions(-D'FACTORY_ADMIN_USERNAME=\"admin\"')
add_definitions(-D'FACTORY_ADMIN_PASSWORD=\"admin\"')
add_definitions(-D'FACTORY_GUEST_USERNAME=\"guest\"')
add_definitions(-D'FACTORY_GUEST_PASSWORD=\"guest\"')
add_definitions(-D'FACTORY_NTP_ENABLED=true')
add_definitions(-D'FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/London\"')
add_definitions(-D'FACTORY_NTP_TIME_ZONE_FORMAT=\"GMT0BST,M3.5.0/1,M10.5.0\"')
add_definitions(-D'FACTORY_NTP_SERVER=\"time.google.com\"')
add_definitions(-D'FACTORY_OTA_PORT=8266')
add_definitions(-D'FACTORY_OTA_PASSWORD=\"esp-react\"')
add_definitions(-D'FACTORY_OTA_ENABLED=true')
add_definitions(-D'FACTORY_MQTT_ENABLED=false')
add_definitions(-D'FACTORY_MQTT_HOST=\"test.mosquitto.org\"')
add_definitions(-D'FACTORY_MQTT_PORT=1883')
add_definitions(-D'FACTORY_MQTT_USERNAME=\"\"')
add_definitions(-D'FACTORY_MQTT_PASSWORD=\"\"')
add_definitions(-D'FACTORY_MQTT_KEEP_ALIVE=60')
add_definitions(-D'FACTORY_MQTT_CLEAN_SESSION=true')
add_definitions(-D'FACTORY_MQTT_MAX_TOPIC_LENGTH=128')
add_definitions(-D'FT_PROJECT=1')
add_definitions(-D'FT_SECURITY=0')
add_definitions(-D'FT_MQTT=0')
add_definitions(-D'FT_NTP=0')
add_definitions(-D'FT_OTA=1')
add_definitions(-D'FT_UPLOAD_FIRMWARE=1')
add_definitions(-D'NO_GLOBAL_ARDUINOOTA')
add_definitions(-D'ENABLE_CORS')
add_definitions(-D'CORS_ORIGIN=\"http://localhost:3000\"')
add_definitions(-D'PROGMEM_WWW')
add_definitions(-D'ESP32')
add_definitions(-D'ESP_PLATFORM')
add_definitions(-D'F_CPU=240000000L')
add_definitions(-D'HAVE_CONFIG_H')
add_definitions(-D'MBEDTLS_CONFIG_FILE=\"mbedtls/esp_config.h\"')
add_definitions(-D'ARDUINO=10805')
add_definitions(-D'ARDUINO_ARCH_ESP32')
add_definitions(-D'ARDUINO_VARIANT=\"esp32\"')
add_definitions(-D'ARDUINO_BOARD=\"Node32s\"')
include_directories("${CMAKE_CURRENT_LIST_DIR}/include")
include_directories("${CMAKE_CURRENT_LIST_DIR}/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/lib/framework")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/ArduinoOTA/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/ESPmDNS/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/Update/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/DNSServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/SPIFFS/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/Ticker/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/node32s/AsyncMqttClient/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/node32s/ESP Async WebServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/WiFi/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/FS/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/node32s/AsyncTCP/src")
include_directories("${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/node32s/ArduinoJson/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/config")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/app_trace")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/app_update")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/asio")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/bootloader_support")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/bt")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/coap")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/console")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/driver")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp-tls")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp32")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp_adc_cal")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp_event")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp_http_client")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp_http_server")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp_https_ota")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp_ringbuf")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/ethernet")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/expat")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/fatfs")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/freemodbus")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/freertos")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/heap")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/idf_test")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/jsmn")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/json")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/libsodium")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/log")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/lwip")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/mbedtls")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/mdns")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/micro-ecc")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/mqtt")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/newlib")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/nghttp")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/nvs_flash")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/openssl")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/protobuf-c")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/protocomm")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/pthread")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/sdmmc")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/smartconfig_ack")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/soc")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/spi_flash")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/spiffs")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/tcp_transport")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/tcpip_adapter")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/ulp")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/vfs")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/wear_levelling")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/wifi_provisioning")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/wpa_supplicant")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/xtensa-debug-module")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp-face")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/esp32-camera")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/tools/sdk/include/fb_gfx")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/cores/esp32")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/variants/esp32")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/AzureIoT/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/BLE/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/BluetoothSerial/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/EEPROM/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/ESP32/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/FFat/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/HTTPClient/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/HTTPUpdate/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/NetBIOS/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/Preferences/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/SD/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/SD_MMC/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/SPI/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/SimpleBLE/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/WebServer/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/WiFiClientSecure/src")
include_directories("$ENV{HOME}/.platformio/packages/framework-arduinoespressif32/libraries/Wire/src")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa32/xtensa-esp32-elf/include/c++/5.2.0")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa32/xtensa-esp32-elf/include/c++/5.2.0/xtensa-esp32-elf")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa32/lib/gcc/xtensa-esp32-elf/5.2.0/include-fixed")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa32/lib/gcc/xtensa-esp32-elf/5.2.0/include")
include_directories("$ENV{HOME}/.platformio/packages/toolchain-xtensa32/xtensa-esp32-elf/include")
include_directories("$ENV{HOME}/.platformio/packages/tool-unity")
FILE(GLOB_RECURSE EXTRA_LIB_SOURCES
${CMAKE_CURRENT_LIST_DIR}/.pio/libdeps/node32s/*.*
)
endif()
FILE(GLOB_RECURSE SRC_LIST
${CMAKE_CURRENT_LIST_DIR}/src/*.*
${CMAKE_CURRENT_LIST_DIR}/lib/*.*
)
list(APPEND SRC_LIST ${EXTRA_LIB_SOURCES})

165
LICENSE.txt Normal file
View File

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

48
factory_settings.ini Normal file
View File

@ -0,0 +1,48 @@
[factory_settings]
build_flags =
; WiFi settings
-D FACTORY_WIFI_SSID=\"\"
-D FACTORY_WIFI_PASSWORD=\"\"
; if unspecified the devices hardware ID will be used
; -D FACTORY_WIFI_HOSTNAME=\"esp-react\"
; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
-D FACTORY_AP_SSID=\"ESP8266-React\" ; 1-64 characters
-D FACTORY_AP_PASSWORD=\"esp-react\" ; 8-64 characters
-D FACTORY_AP_LOCAL_IP=\"192.168.4.1\"
-D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"
-D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"
; User credentials for admin and guest user
-D FACTORY_ADMIN_USERNAME=\"admin\"
-D FACTORY_ADMIN_PASSWORD=\"admin\"
-D FACTORY_GUEST_USERNAME=\"guest\"
-D FACTORY_GUEST_PASSWORD=\"guest\"
; NTP settings
-D FACTORY_NTP_ENABLED=true
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/London\"
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"GMT0BST,M3.5.0/1,M10.5.0\"
-D FACTORY_NTP_SERVER=\"time.google.com\"
; OTA settings
-D FACTORY_OTA_PORT=8266
-D FACTORY_OTA_PASSWORD=\"esp-react\"
-D FACTORY_OTA_ENABLED=true
; MQTT settings
-D FACTORY_MQTT_ENABLED=false
-D FACTORY_MQTT_HOST=\"test.mosquitto.org\"
-D FACTORY_MQTT_PORT=1883
-D FACTORY_MQTT_USERNAME=\"\"
-D FACTORY_MQTT_PASSWORD=\"\"
; if unspecified the devices hardware ID will be used
;-D FACTORY_MQTT_CLIENT_ID=\"esp-react\"
-D FACTORY_MQTT_KEEP_ALIVE=60
-D FACTORY_MQTT_CLEAN_SESSION=true
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128
; JWT Secret
; if unspecified the devices hardware ID will be used
; -D FACTORY_JWT_SECRET=\"esp8266-react\"

8
features.ini Normal file
View File

@ -0,0 +1,8 @@
[features]
build_flags =
-D FT_PROJECT=1
-D FT_SECURITY=0
-D FT_MQTT=0
-D FT_NTP=0
-D FT_OTA=1
-D FT_UPLOAD_FIRMWARE=1

5
interface/.env Normal file
View File

@ -0,0 +1,5 @@
# This is the name of your project. It appears on the sign-in page and in the menu bar.
REACT_APP_PROJECT_NAME=ESP8266 React
# This is the url path your project will be exposed under.
REACT_APP_PROJECT_PATH=project

View File

@ -0,0 +1,4 @@
# Change the IP address to that of your ESP device to enable local development of the UI.
# Remember to also enable CORS in platformio.ini before uploading the code to the device.
REACT_APP_HTTP_ROOT=http://192.168.0.230
REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.230

View File

@ -0,0 +1 @@
GENERATE_SOURCEMAP=false

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -0,0 +1,12 @@
{
"name":"ESP8266 React",
"icons":[
{
"src":"/app/icon.png",
"sizes":"48x48 72x72 96x96 128x128 256x256"
}
],
"start_url":"/",
"display":"fullscreen",
"orientation":"any"
}

View File

@ -0,0 +1,22 @@
/* Just supporting latin due to size constrains on the esp chip */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/li.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/me.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}

BIN
interface/build/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,37 @@
const ManifestPlugin = require('webpack-manifest-plugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const ProgmemGenerator = require('./progmem-generator.js');
const path = require('path');
const fs = require('fs');
module.exports = function override(config, env) {
if (env === "production") {
// rename the ouput file, we need it's path to be short, for embedded FS
config.output.filename = 'js/[id].[chunkhash:4].js';
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
// take out the manifest and service worker plugins
config.plugins = config.plugins.filter(plugin => !(plugin instanceof ManifestPlugin));
config.plugins = config.plugins.filter(plugin => !(plugin instanceof WorkboxWebpackPlugin.GenerateSW));
// shorten css filenames
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
miniCssExtractPlugin.options.filename = "css/[id].[contenthash:4].css";
miniCssExtractPlugin.options.chunkFilename = "css/[id].[contenthash:4].c.css";
// build progmem data files
config.plugins.push(new ProgmemGenerator({ outputPath: "../lib/framework/WWWData.h", bytesPerLine: 20 }));
// add compression plugin, compress javascript
config.plugins.push(new CompressionPlugin({
filename: "[path].gz[query]",
algorithm: "gzip",
test: /\.(js)$/,
deleteOriginalAssets: true
}));
}
return config;
}

14652
interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
interface/package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "esp8266-react",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@types/jwt-decode": "^3.1.0",
"@types/lodash": "^4.14.165",
"@types/node": "^12.12.32",
"@types/react": "^16.9.56",
"@types/react-dom": "^16.9.9",
"@types/react-material-ui-form-validator": "^2.1.0",
"@types/react-router": "^5.1.8",
"@types/react-router-dom": "^5.1.6",
"compression-webpack-plugin": "^4.0.0",
"jwt-decode": "^3.1.1",
"lodash": "^4.17.20",
"mime-types": "^2.1.27",
"moment": "^2.29.1",
"notistack": "^1.0.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-dropzone": "^11.2.4",
"react-form-validator-core": "^1.0.0",
"react-material-ui-form-validator": "^2.1.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.4",
"sockette": "^2.0.6",
"typescript": "^4.0.2",
"zlib": "^1.0.5"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-app-rewired": "^2.1.6"
}
}

View File

@ -0,0 +1,122 @@
const { resolve, relative, sep } = require('path');
const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
var zlib = require('zlib');
var mime = require('mime-types');
const ARDUINO_INCLUDES = "#include <Arduino.h>\n\n";
function getFilesSync(dir, files = []) {
readdirSync(dir, { withFileTypes: true }).forEach(entry => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
getFilesSync(entryPath, files);
} else {
files.push(entryPath);
}
})
return files;
}
function coherseToBuffer(input) {
return Buffer.isBuffer(input) ? input : Buffer.from(input);
}
function cleanAndOpen(path) {
if (existsSync(path)) {
unlinkSync(path);
}
return createWriteStream(path, { flags: "w+" });
}
class ProgmemGenerator {
constructor(options = {}) {
const { outputPath, bytesPerLine = 20, indent = " ", includes = ARDUINO_INCLUDES } = options;
this.options = { outputPath, bytesPerLine, indent, includes };
}
apply(compiler) {
compiler.hooks.emit.tapAsync(
{ name: 'ProgmemGenerator' },
(compilation, callback) => {
const { outputPath, bytesPerLine, indent, includes } = this.options;
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
try {
const writeIncludes = () => {
writeStream.write(includes);
}
const writeFile = (relativeFilePath, buffer) => {
const variable = "ESP_REACT_DATA_" + fileInfo.length;
const mimeType = mime.lookup(relativeFilePath);
var size = 0;
writeStream.write("const uint8_t " + variable + "[] PROGMEM = {");
const zipBuffer = zlib.gzipSync(buffer);
zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) {
writeStream.write("\n");
writeStream.write(indent);
}
writeStream.write("0x" + ("00" + b.toString(16).toUpperCase()).substr(-2) + ",");
size++;
});
if (size % bytesPerLine) {
writeStream.write("\n");
}
writeStream.write("};\n\n");
fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'),
mimeType,
variable,
size
});
};
const writeFiles = () => {
// process static files
const buildPath = compilation.options.output.path;
for (const filePath of getFilesSync(buildPath)) {
const readStream = readFileSync(filePath);
const relativeFilePath = relative(buildPath, filePath);
writeFile(relativeFilePath, readStream);
}
// process assets
const { assets } = compilation;
Object.keys(assets).forEach((relativeFilePath) => {
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
});
}
const generateWWWClass = () => {
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
class WWWData {
${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map(file => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`).join('\n')}
${indent.repeat(2)}}
};
`;
}
const writeWWWClass = () => {
writeStream.write(generateWWWClass());
}
writeIncludes();
writeFiles();
writeWWWClass();
writeStream.on('finish', () => {
callback();
});
} finally {
writeStream.end();
}
}
);
}
}
module.exports = ProgmemGenerator;

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -0,0 +1,12 @@
{
"name":"ESP8266 React",
"icons":[
{
"src":"/app/icon.png",
"sizes":"48x48 72x72 96x96 128x128 256x256"
}
],
"start_url":"/",
"display":"fullscreen",
"orientation":"any"
}

View File

@ -0,0 +1,22 @@
/* Just supporting latin due to size constrains on the esp chip */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/li.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/me.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css">
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json">
<title>ESP8266 React</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

50
interface/src/App.tsx Normal file
View File

@ -0,0 +1,50 @@
import React, { Component, RefObject } from 'react';
import { Redirect, Route, Switch } from 'react-router';
import { SnackbarProvider } from 'notistack';
import { IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import AppRouting from './AppRouting';
import CustomMuiTheme from './CustomMuiTheme';
import { PROJECT_NAME } from './api';
import FeaturesWrapper from './features/FeaturesWrapper';
// this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid.
const unauthorizedRedirect = () => <Redirect to="/" />;
class App extends Component {
notistackRef: RefObject<any> = React.createRef();
componentDidMount() {
document.title = PROJECT_NAME;
}
onClickDismiss = (key: string | number | undefined) => () => {
this.notistackRef.current.closeSnackbar(key);
}
render() {
return (
<CustomMuiTheme>
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={this.notistackRef}
action={(key) => (
<IconButton onClick={this.onClickDismiss(key)} size="small">
<CloseIcon />
</IconButton>
)}>
<FeaturesWrapper>
<Switch>
<Route exact path="/unauthorized" component={unauthorizedRedirect} />
<Route component={AppRouting} />
</Switch>
</FeaturesWrapper>
</SnackbarProvider>
</CustomMuiTheme>
);
}
}
export default App

View File

@ -0,0 +1,60 @@
import React, { Component } from 'react';
import { Switch, Redirect } from 'react-router';
import * as Authentication from './authentication/Authentication';
import AuthenticationWrapper from './authentication/AuthenticationWrapper';
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
import SignIn from './SignIn';
import ProjectRouting from './project/ProjectRouting';
import WiFiConnection from './wifi/WiFiConnection';
import AccessPoint from './ap/AccessPoint';
import NetworkTime from './ntp/NetworkTime';
import Security from './security/Security';
import System from './system/System';
import { PROJECT_PATH } from './api';
import Mqtt from './mqtt/Mqtt';
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
import { Features } from './features/types';
export const getDefaultRoute = (features: Features) => features.project ? `/${PROJECT_PATH}/` : "/wifi/";
class AppRouting extends Component<WithFeaturesProps> {
componentDidMount() {
Authentication.clearLoginRedirect();
}
render() {
const { features } = this.props;
return (
<AuthenticationWrapper>
<Switch>
{features.security && (
<UnauthenticatedRoute exact path="/" component={SignIn} />
)}
{features.project && (
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} />
)}
<AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} />
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
{features.ntp && (
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
)}
{features.mqtt && (
<AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} />
)}
{features.security && (
<AuthenticatedRoute exact path="/security/*" component={Security} />
)}
<AuthenticatedRoute exact path="/system/*" component={System} />
<Redirect to={getDefaultRoute(features)} />
</Switch>
</AuthenticationWrapper>
)
}
}
export default withFeatures(AppRouting);

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import { CssBaseline } from '@material-ui/core';
import { MuiThemeProvider, createMuiTheme, StylesProvider } from '@material-ui/core/styles';
import { blueGrey, indigo, orange, red, green } from '@material-ui/core/colors';
const theme = createMuiTheme({
palette: {
primary: indigo,
secondary: blueGrey,
info: {
main: blueGrey[900]
},
warning: {
main: orange[500]
},
error: {
main: red[500]
},
success: {
main: green[500]
}
},
});
export default class CustomMuiTheme extends Component {
render() {
return (
<StylesProvider>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{this.props.children}
</MuiThemeProvider>
</StylesProvider>
);
}
}

147
interface/src/SignIn.tsx Normal file
View File

@ -0,0 +1,147 @@
import React, { Component } from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles';
import { Paper, Typography, Fab } from '@material-ui/core';
import ForwardIcon from '@material-ui/icons/Forward';
import { withAuthenticationContext, AuthenticationContextProps } from './authentication/AuthenticationContext';
import {PasswordValidator} from './components';
import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
const styles = (theme: Theme) => createStyles({
signInPage: {
display: "flex",
height: "100vh",
margin: "auto",
padding: theme.spacing(2),
justifyContent: "center",
flexDirection: "column",
maxWidth: theme.breakpoints.values.sm
},
signInPanel: {
textAlign: "center",
padding: theme.spacing(2),
paddingTop: "200px",
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: "no-repeat",
backgroundPosition: "50% " + theme.spacing(2) + "px",
backgroundSize: "auto 150px",
width: "100%"
},
extendedIcon: {
marginRight: theme.spacing(0.5),
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
});
type SignInProps = WithSnackbarProps & WithStyles<typeof styles> & AuthenticationContextProps;
interface SignInState {
username: string,
password: string,
processing: boolean
}
class SignIn extends Component<SignInProps, SignInState> {
constructor(props: SignInProps) {
super(props);
this.state = {
username: '',
password: '',
processing: false
};
}
updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = event.currentTarget;
this.setState(prevState => ({
...prevState,
[name]: value,
}))
};
onSubmit = () => {
const { username, password } = this.state;
const { authenticationContext } = this.props;
this.setState({ processing: true });
fetch(SIGN_IN_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: new Headers({
'Content-Type': 'application/json'
})
})
.then(response => {
if (response.status === 200) {
return response.json();
} else if (response.status === 401) {
throw Error("Invalid credentials.");
} else {
throw Error("Invalid status code: " + response.status);
}
}).then(json => {
authenticationContext.signIn(json.access_token);
})
.catch(error => {
this.props.enqueueSnackbar(error.message, {
variant: 'warning',
});
this.setState({ processing: false });
});
};
render() {
const { username, password, processing } = this.state;
const { classes } = this.props;
return (
<div className={classes.signInPage}>
<Paper className={classes.signInPanel}>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<ValidatorForm onSubmit={this.onSubmit}>
<TextValidator
disabled={processing}
validators={['required']}
errorMessages={['Username is required']}
name="username"
label="Username"
fullWidth
variant="outlined"
value={username}
onChange={this.updateInputElement}
margin="normal"
inputProps={{
autoCapitalize: "none",
autoCorrect: "off",
}}
/>
<PasswordValidator
disabled={processing}
validators={['required']}
errorMessages={['Password is required']}
name="password"
label="Password"
fullWidth
variant="outlined"
value={password}
onChange={this.updateInputElement}
margin="normal"
/>
<Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}>
<ForwardIcon className={classes.extendedIcon} />
Sign In
</Fab>
</ValidatorForm>
</Paper>
</div>
);
}
}
export default withAuthenticationContext(withSnackbar(withStyles(styles)(SignIn)));

View File

@ -0,0 +1,5 @@
import { APSettings, APProvisionMode } from "./types";
export const isAPEnabled = ({ provision_mode }: APSettings) => {
return provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
}

View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import { AP_SETTINGS_ENDPOINT } from '../api';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
import APSettingsForm from './APSettingsForm';
import { APSettings } from './types';
type APSettingsControllerProps = RestControllerProps<APSettings>;
class APSettingsController extends Component<APSettingsControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="Access Point Settings" titleGutter>
<RestFormLoader
{...this.props}
render={formProps => <APSettingsForm {...formProps} />}
/>
</SectionContent>
)
}
}
export default restController(AP_SETTINGS_ENDPOINT, APSettingsController);

View File

@ -0,0 +1,106 @@
import React, { Fragment } from 'react';
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator';
import MenuItem from '@material-ui/core/MenuItem';
import SaveIcon from '@material-ui/icons/Save';
import { PasswordValidator, RestFormProps, FormActions, FormButton } from '../components';
import { isAPEnabled } from './APModes';
import { APSettings, APProvisionMode } from './types';
import { isIP } from '../validators';
type APSettingsFormProps = RestFormProps<APSettings>;
class APSettingsForm extends React.Component<APSettingsFormProps> {
componentWillMount() {
ValidatorForm.addValidationRule('isIP', isIP);
}
render() {
const { data, handleValueChange, saveData } = this.props;
return (
<ValidatorForm onSubmit={saveData} ref="APSettingsForm">
<SelectValidator name="provision_mode"
label="Provide Access Point&hellip;"
value={data.provision_mode}
fullWidth
variant="outlined"
onChange={handleValueChange('provision_mode')}
margin="normal">
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>Always</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>When WiFi Disconnected</MenuItem>
<MenuItem value={APProvisionMode.AP_NEVER}>Never</MenuItem>
</SelectValidator>
{
isAPEnabled(data) &&
<Fragment>
<TextValidator
validators={['required', 'matchRegexp:^.{1,32}$']}
errorMessages={['Access Point SSID is required', 'Access Point SSID must be 32 characters or less']}
name="ssid"
label="Access Point SSID"
fullWidth
variant="outlined"
value={data.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
<PasswordValidator
validators={['required', 'matchRegexp:^.{8,64}$']}
errorMessages={['Access Point Password is required', 'Access Point Password must be 8-64 characters']}
name="password"
label="Access Point Password"
fullWidth
variant="outlined"
value={data.password}
onChange={handleValueChange('password')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Local IP is required', 'Must be an IP address']}
name="local_ip"
label="Local IP"
fullWidth
variant="outlined"
value={data.local_ip}
onChange={handleValueChange('local_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Gateway IP is required', 'Must be an IP address']}
name="gateway_ip"
label="Gateway"
fullWidth
variant="outlined"
value={data.gateway_ip}
onChange={handleValueChange('gateway_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Subnet mask is required', 'Must be an IP address']}
name="subnet_mask"
label="Subnet"
fullWidth
variant="outlined"
value={data.subnet_mask}
onChange={handleValueChange('subnet_mask')}
margin="normal"
/>
</Fragment>
}
<FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
Save
</FormButton>
</FormActions>
</ValidatorForm>
);
}
}
export default APSettingsForm;

View File

@ -0,0 +1,28 @@
import { Theme } from "@material-ui/core";
import { APStatus, APNetworkStatus } from "./types";
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return theme.palette.success.main;
case APNetworkStatus.INACTIVE:
return theme.palette.info.main;
case APNetworkStatus.LINGERING:
return theme.palette.warning.main;
default:
return theme.palette.warning.main;
}
}
export const apStatus = ({ status }: APStatus) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return "Active";
case APNetworkStatus.INACTIVE:
return "Inactive";
case APNetworkStatus.LINGERING:
return "Lingering until idle";
default:
return "Unknown";
}
};

View File

@ -0,0 +1,29 @@
import React, { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
import { AP_STATUS_ENDPOINT } from '../api';
import APStatusForm from './APStatusForm';
import { APStatus } from './types';
type APStatusControllerProps = RestControllerProps<APStatus>;
class APStatusController extends Component<APStatusControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="Access Point Status">
<RestFormLoader
{...this.props}
render={formProps => <APStatusForm {...formProps} />}
/>
</SectionContent>
)
}
}
export default restController(AP_STATUS_ENDPOINT, APStatusController);

View File

@ -0,0 +1,78 @@
import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import ComputerIcon from '@material-ui/icons/Computer';
import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components';
import { apStatusHighlight, apStatus } from './APStatus';
import { APStatus } from './types';
type APStatusFormProps = RestFormProps<APStatus> & WithTheme;
class APStatusForm extends Component<APStatusFormProps> {
createListItems() {
const { data, theme } = this.props
return (
<Fragment>
<ListItem>
<ListItemAvatar>
<HighlightAvatar color={apStatusHighlight(data, theme)}>
<SettingsInputAntennaIcon />
</HighlightAvatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="AP Clients" secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
);
}
render() {
return (
<Fragment>
<List>
{this.createListItems()}
</List>
<FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}>
Refresh
</FormButton>
</FormActions>
</Fragment>
);
}
}
export default withTheme(APStatusForm);

View File

@ -0,0 +1,38 @@
import React, { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
import { Tabs, Tab } from '@material-ui/core';
import { AuthenticatedContextProps, withAuthenticatedContext, AuthenticatedRoute } from '../authentication';
import { MenuAppBar } from '../components';
import APSettingsController from './APSettingsController';
import APStatusController from './APStatusController';
type AccessPointProps = AuthenticatedContextProps & RouteComponentProps;
class AccessPoint extends Component<AccessPointProps> {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path);
};
render() {
const { authenticatedContext } = this.props;
return (
<MenuAppBar sectionTitle="Access Point">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
<Tab value="/ap/status" label="Access Point Status" />
<Tab value="/ap/settings" label="Access Point Settings" disabled={!authenticatedContext.me.admin} />
</Tabs>
<Switch>
<AuthenticatedRoute exact path="/ap/status" component={APStatusController} />
<AuthenticatedRoute exact path="/ap/settings" component={APSettingsController} />
<Redirect to="/ap/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticatedContext(AccessPoint);

27
interface/src/ap/types.ts Normal file
View File

@ -0,0 +1,27 @@
export enum APProvisionMode {
AP_MODE_ALWAYS = 0,
AP_MODE_DISCONNECTED = 1,
AP_NEVER = 2
}
export enum APNetworkStatus {
ACTIVE = 0,
INACTIVE = 1,
LINGERING = 2
}
export interface APStatus {
status: APNetworkStatus;
ip_address: string;
mac_address: string;
station_num: number;
}
export interface APSettings {
provision_mode: APProvisionMode;
ssid: string;
password: string;
local_ip: string;
gateway_ip: string;
subnet_mask: string;
}

View File

@ -0,0 +1,22 @@
import { ENDPOINT_ROOT } from './Env';
export const FEATURES_ENDPOINT = ENDPOINT_ROOT + "features";
export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + "ntpStatus";
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "ntpSettings";
export const TIME_ENDPOINT = ENDPOINT_ROOT + "time";
export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "apSettings";
export const AP_STATUS_ENDPOINT = ENDPOINT_ROOT + "apStatus";
export const SCAN_NETWORKS_ENDPOINT = ENDPOINT_ROOT + "scanNetworks";
export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + "listNetworks";
export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings";
export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus";
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "otaSettings";
export const UPLOAD_FIRMWARE_ENDPOINT = ENDPOINT_ROOT + "uploadFirmware";
export const MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "mqttSettings";
export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + "mqttStatus";
export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + "systemStatus";
export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + "signIn";
export const VERIFY_AUTHORIZATION_ENDPOINT = ENDPOINT_ROOT + "verifyAuthorization";
export const SECURITY_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "securitySettings";
export const RESTART_ENDPOINT = ENDPOINT_ROOT + "restart";
export const FACTORY_RESET_ENDPOINT = ENDPOINT_ROOT + "factoryReset";

24
interface/src/api/Env.ts Normal file
View File

@ -0,0 +1,24 @@
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME!;
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH!;
export const ENDPOINT_ROOT = calculateEndpointRoot("/rest/");
export const WEB_SOCKET_ROOT = calculateWebSocketRoot("/ws/");
function calculateEndpointRoot(endpointPath: string) {
const httpRoot = process.env.REACT_APP_HTTP_ROOT;
if (httpRoot) {
return httpRoot + endpointPath;
}
const location = window.location;
return location.protocol + "//" + location.host + endpointPath;
}
function calculateWebSocketRoot(webSocketPath: string) {
const webSocketRoot = process.env.REACT_APP_WEB_SOCKET_ROOT;
if (webSocketRoot) {
return webSocketRoot + webSocketPath;
}
const location = window.location;
const webProtocol = location.protocol === "https:" ? "wss:" : "ws:";
return webProtocol + "//" + location.host + webSocketPath;
}

View File

@ -0,0 +1,2 @@
export * from './Env'
export * from './Endpoints'

View File

@ -0,0 +1,42 @@
import * as React from 'react';
import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-dom";
import { withSnackbar, WithSnackbarProps } from 'notistack';
import * as Authentication from './Authentication';
import { withAuthenticationContext, AuthenticationContextProps, AuthenticatedContext } from './AuthenticationContext';
type ChildComponent = React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
interface AuthenticatedRouteProps extends RouteProps, WithSnackbarProps, AuthenticationContextProps {
component: ChildComponent;
}
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
export class AuthenticatedRoute extends React.Component<AuthenticatedRouteProps> {
render() {
const { enqueueSnackbar, authenticationContext, component: Component, ...rest } = this.props;
const { location } = this.props;
const renderComponent: RenderComponent = (props) => {
if (authenticationContext.me) {
return (
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContext}>
<Component {...props} />
</AuthenticatedContext.Provider>
);
}
Authentication.storeLoginRedirect(location);
enqueueSnackbar("Please sign in to continue.", { variant: 'info' });
return (
<Redirect to='/' />
);
}
return (
<Route {...rest} render={renderComponent} />
);
}
}
export default withSnackbar(withAuthenticationContext(AuthenticatedRoute));

View File

@ -0,0 +1,114 @@
import * as H from 'history';
import history from '../history';
import { Features } from '../features/types';
import { getDefaultRoute } from '../AppRouting';
export const ACCESS_TOKEN = 'access_token';
export const SIGN_IN_PATHNAME = 'signInPathname';
export const SIGN_IN_SEARCH = 'signInSearch';
/**
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
*/
export function getStorage() {
return localStorage || sessionStorage;
}
export function storeLoginRedirect(location?: H.Location) {
if (location) {
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
getStorage().setItem(SIGN_IN_SEARCH, location.search);
}
}
export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_PATHNAME);
getStorage().removeItem(SIGN_IN_SEARCH);
}
export function fetchLoginRedirect(features: Features): H.LocationDescriptorObject {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect();
return {
pathname: signInPathname || getDefaultRoute(features),
search: (signInPathname && signInSearch) || undefined
};
}
/**
* Wraps the normal fetch routene with one with provides the access token if present.
*/
export function authorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> {
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
params = params || {};
params.credentials = 'include';
params.headers = {
...params.headers,
"Authorization": 'Bearer ' + accessToken
};
}
return fetch(url, params);
}
/**
* fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request
* for a single file upload and takes care of adding the Authroization header and redirecting on
* authroization errors as we do for normal fetch operations.
*/
export function redirectingAuthorizedUpload(xhr: XMLHttpRequest, url: string, file: File, onProgress: (event: ProgressEvent<EventTarget>) => void): Promise<void> {
return new Promise((resolve, reject) => {
xhr.open("POST", url, true);
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
xhr.withCredentials = true;
xhr.setRequestHeader("Authorization", 'Bearer ' + accessToken);
}
xhr.upload.onprogress = onProgress;
xhr.onload = function () {
if (xhr.status === 401 || xhr.status === 403) {
history.push("/unauthorized");
} else {
resolve();
}
};
xhr.onerror = function (event: ProgressEvent<EventTarget>) {
reject(new DOMException('Error', 'UploadError'));
};
xhr.onabort = function () {
reject(new DOMException('Aborted', 'AbortError'));
};
const formData = new FormData();
formData.append('file', file);
xhr.send(formData);
});
}
/**
* Wraps the normal fetch routene which redirects on 401 response.
*/
export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> {
return new Promise<Response>((resolve, reject) => {
authorizedFetch(url, params).then(response => {
if (response.status === 401 || response.status === 403) {
history.push("/unauthorized");
} else {
resolve(response);
}
}).catch(error => {
reject(error);
});
});
}
export function addAccessTokenParameter(url: string) {
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (!accessToken) {
return url;
}
const parsedUrl = new URL(url);
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
return parsedUrl.toString();
}

View File

@ -0,0 +1,59 @@
import * as React from "react";
export interface Me {
username: string;
admin: boolean;
}
export interface AuthenticationContext {
refresh: () => void;
signIn: (accessToken: string) => void;
signOut: () => void;
me?: Me;
}
const AuthenticationContextDefaultValue = {} as AuthenticationContext
export const AuthenticationContext = React.createContext(
AuthenticationContextDefaultValue
);
export interface AuthenticationContextProps {
authenticationContext: AuthenticationContext;
}
export function withAuthenticationContext<T extends AuthenticationContextProps>(Component: React.ComponentType<T>) {
return class extends React.Component<Omit<T, keyof AuthenticationContextProps>> {
render() {
return (
<AuthenticationContext.Consumer>
{authenticationContext => <Component {...this.props as T} authenticationContext={authenticationContext} />}
</AuthenticationContext.Consumer>
);
}
};
}
export interface AuthenticatedContext extends AuthenticationContext {
me: Me;
}
const AuthenticatedContextDefaultValue = {} as AuthenticatedContext
export const AuthenticatedContext = React.createContext(
AuthenticatedContextDefaultValue
);
export interface AuthenticatedContextProps {
authenticatedContext: AuthenticatedContext;
}
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(Component: React.ComponentType<T>) {
return class extends React.Component<Omit<T, keyof AuthenticatedContextProps>> {
render() {
return (
<AuthenticatedContext.Consumer>
{authenticatedContext => <Component {...this.props as T} authenticatedContext={authenticatedContext} />}
</AuthenticatedContext.Consumer>
);
}
};
}

View File

@ -0,0 +1,109 @@
import * as React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import jwtDecode from 'jwt-decode';
import history from '../history'
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
import { AuthenticationContext, Me } from './AuthenticationContext';
import FullScreenLoading from '../components/FullScreenLoading';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
interface AuthenticationWrapperState {
context: AuthenticationContext;
initialized: boolean;
}
type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> {
constructor(props: AuthenticationWrapperProps) {
super(props);
this.state = {
context: {
refresh: this.refresh,
signIn: this.signIn,
signOut: this.signOut,
},
initialized: false
};
}
componentDidMount() {
this.refresh();
}
render() {
return (
<React.Fragment>
{this.state.initialized ? this.renderContent() : this.renderContentLoading()}
</React.Fragment>
);
}
renderContent() {
return (
<AuthenticationContext.Provider value={this.state.context}>
{this.props.children}
</AuthenticationContext.Provider>
);
}
renderContentLoading() {
return (
<FullScreenLoading />
);
}
refresh = () => {
if (!this.props.features.security) {
this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
return;
}
const accessToken = getStorage().getItem(ACCESS_TOKEN)
if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
.then(response => {
const me = response.status === 200 ? decodeMeJWT(accessToken) : undefined;
this.setState({ initialized: true, context: { ...this.state.context, me } });
}).catch(error => {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } });
this.props.enqueueSnackbar("Error verifying authorization: " + error.message, {
variant: 'error',
});
});
} else {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } });
}
}
signIn = (accessToken: string) => {
try {
getStorage().setItem(ACCESS_TOKEN, accessToken);
const me: Me = decodeMeJWT(accessToken);
this.setState({ context: { ...this.state.context, me } });
this.props.enqueueSnackbar(`Logged in as ${me.username}`, { variant: 'success' });
} catch (err) {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } });
throw new Error("Failed to parse JWT " + err.message);
}
}
signOut = () => {
getStorage().removeItem(ACCESS_TOKEN);
this.setState({
context: {
...this.state.context,
me: undefined
}
});
this.props.enqueueSnackbar("You have signed out.", { variant: 'success', });
history.push('/');
}
}
export default withFeatures(withSnackbar(AuthenticationWrapper))

View File

@ -0,0 +1,30 @@
import * as React from 'react';
import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-dom";
import { withAuthenticationContext, AuthenticationContextProps } from './AuthenticationContext';
import * as Authentication from './Authentication';
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
interface UnauthenticatedRouteProps extends RouteProps, AuthenticationContextProps, WithFeaturesProps {
component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
}
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> {
public render() {
const { authenticationContext, component: Component, features, ...rest } = this.props;
const renderComponent: RenderComponent = (props) => {
if (authenticationContext.me) {
return (<Redirect to={Authentication.fetchLoginRedirect(features)} />);
}
return (<Component {...props} />);
}
return (
<Route {...rest} render={renderComponent} />
);
}
}
export default withFeatures(withAuthenticationContext(UnauthenticatedRoute));

View File

@ -0,0 +1,6 @@
export { default as AuthenticatedRoute } from './AuthenticatedRoute';
export { default as AuthenticationWrapper } from './AuthenticationWrapper';
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
export * from './Authentication';
export * from './AuthenticationContext';

View File

@ -0,0 +1,59 @@
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/styles';
import { Paper, Typography, Box, CssBaseline } from "@material-ui/core";
import WarningIcon from "@material-ui/icons/Warning"
const styles = makeStyles(
{
siteErrorPage: {
display: "flex",
height: "100vh",
justifyContent: "center",
flexDirection: "column"
},
siteErrorPagePanel: {
textAlign: "center",
padding: "280px 0 40px 0",
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: "no-repeat",
backgroundPosition: "50% 40px",
backgroundSize: "200px auto",
width: "100%",
}
}
);
interface ApplicationErrorProps {
error?: string;
}
const ApplicationError: FC<ApplicationErrorProps> = ({ error }) => {
const classes = styles();
return (
<div className={classes.siteErrorPage}>
<CssBaseline />
<Paper className={classes.siteErrorPagePanel} elevation={10}>
<Box display="flex" flexDirection="row" justifyContent="center" alignItems="center" mb={2}>
<WarningIcon fontSize="large" color="error" />
<Box ml={2}>
<Typography variant="h4">
Application error
</Typography>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Failed to configure the application, please refresh to try again.
</Typography>
{error &&
(
<Typography variant="subtitle2" gutterBottom>
Error: {error}
</Typography>
)
}
</Paper>
</div>
);
}
export default ApplicationError;

View File

@ -0,0 +1,10 @@
import React, { FC } from "react";
import { FormControlLabel, FormControlLabelProps } from "@material-ui/core";
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div>
<FormControlLabel {...props} />
</div>
)
export default BlockFormControlLabel;

View File

@ -0,0 +1,11 @@
import { Button, styled } from "@material-ui/core";
const ErrorButton = styled(Button)(({ theme }) => ({
color: theme.palette.getContrastText(theme.palette.error.main),
backgroundColor: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.dark,
}
}));
export default ErrorButton;

View File

@ -0,0 +1,7 @@
import { styled, Box } from "@material-ui/core";
const FormActions = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(1)
}));
export default FormActions;

View File

@ -0,0 +1,13 @@
import { Button, styled } from "@material-ui/core";
const FormButton = styled(Button)(({ theme }) => ({
margin: theme.spacing(0, 1),
'&:last-child': {
marginRight: 0,
},
'&:first-child': {
marginLeft: 0,
}
}));
export default FormButton;

View File

@ -0,0 +1,32 @@
import React from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
import { Typography, Theme } from '@material-ui/core';
import { makeStyles, createStyles } from '@material-ui/styles';
const useStyles = makeStyles((theme: Theme) => createStyles({
fullScreenLoading: {
padding: theme.spacing(2),
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
flexDirection: "column"
},
progress: {
margin: theme.spacing(4),
}
}));
const FullScreenLoading = () => {
const classes = useStyles();
return (
<div className={classes.fullScreenLoading}>
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4">
Loading&hellip;
</Typography>
</div>
)
}
export default FullScreenLoading;

View File

@ -0,0 +1,23 @@
import { Avatar, makeStyles } from "@material-ui/core";
import React, { FC } from "react";
interface HighlightAvatarProps {
color: string;
}
const useStyles = makeStyles({
root: (props: HighlightAvatarProps) => ({
backgroundColor: props.color
})
});
const HighlightAvatar: FC<HighlightAvatarProps> = (props) => {
const classes = useStyles(props);
return (
<Avatar className={classes.root}>
{props.children}
</Avatar>
);
}
export default HighlightAvatar;

View File

@ -0,0 +1,286 @@
import React, { RefObject, Fragment } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { Drawer, AppBar, Toolbar, Avatar, Divider, Button, Box, IconButton } from '@material-ui/core';
import { ClickAwayListener, Popper, Hidden, Typography } from '@material-ui/core';
import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core';
import { Card, CardContent, CardActions } from '@material-ui/core';
import { withStyles, createStyles, Theme, WithTheme, WithStyles, withTheme } from '@material-ui/core/styles';
import WifiIcon from '@material-ui/icons/Wifi';
import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import LockIcon from '@material-ui/icons/Lock';
import MenuIcon from '@material-ui/icons/Menu';
import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../api';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
const drawerWidth = 290;
const styles = (theme: Theme) => createStyles({
root: {
display: 'flex',
},
drawer: {
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0,
},
},
title: {
flexGrow: 1
},
appBar: {
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`,
},
},
toolbarImage: {
[theme.breakpoints.up('xs')]: {
height: 24,
marginRight: theme.spacing(2)
},
[theme.breakpoints.up('sm')]: {
height: 36,
marginRight: theme.spacing(3)
},
},
menuButton: {
marginRight: theme.spacing(2),
[theme.breakpoints.up('md')]: {
display: 'none',
},
},
toolbar: theme.mixins.toolbar,
drawerPaper: {
width: drawerWidth,
},
content: {
flexGrow: 1
},
authMenu: {
zIndex: theme.zIndex.tooltip,
maxWidth: 400,
},
authMenuActions: {
padding: theme.spacing(2),
"& > * + *": {
marginLeft: theme.spacing(2),
}
},
});
interface MenuAppBarState {
mobileOpen: boolean;
authMenuOpen: boolean;
}
interface MenuAppBarProps extends WithFeaturesProps, AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps {
sectionTitle: string;
}
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
constructor(props: MenuAppBarProps) {
super(props);
this.state = {
mobileOpen: false,
authMenuOpen: false
};
}
anchorRef: RefObject<HTMLButtonElement> = React.createRef();
handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen });
}
handleClose = (event: React.MouseEvent<Document>) => {
if (this.anchorRef.current && this.anchorRef.current.contains(event.currentTarget)) {
return;
}
this.setState({ authMenuOpen: false });
}
handleDrawerToggle = () => {
this.setState({ mobileOpen: !this.state.mobileOpen });
};
render() {
const { classes, theme, children, sectionTitle, authenticatedContext, features } = this.props;
const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url;
const drawer = (
<div>
<Toolbar>
<Box display="flex">
<img src="/app/icon.png" className={classes.toolbarImage} alt={PROJECT_NAME} />
</Box>
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
<Divider absolute />
</Toolbar>
{features.project && (
<Fragment>
<ProjectMenu />
<Divider />
</Fragment>
)}
<List>
<ListItem to='/wifi/' selected={path.startsWith('/wifi/')} button component={Link}>
<ListItemIcon>
<WifiIcon />
</ListItemIcon>
<ListItemText primary="WiFi Connection" />
</ListItem>
<ListItem to='/ap/' selected={path.startsWith('/ap/')} button component={Link}>
<ListItemIcon>
<SettingsInputAntennaIcon />
</ListItemIcon>
<ListItemText primary="Access Point" />
</ListItem>
{features.ntp && (
<ListItem to='/ntp/' selected={path.startsWith('/ntp/')} button component={Link}>
<ListItemIcon>
<AccessTimeIcon />
</ListItemIcon>
<ListItemText primary="Network Time" />
</ListItem>
)}
{features.mqtt && (
<ListItem to='/mqtt/' selected={path.startsWith('/mqtt/')} button component={Link}>
<ListItemIcon>
<DeviceHubIcon />
</ListItemIcon>
<ListItemText primary="MQTT" />
</ListItem>
)}
{features.security && (
<ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}>
<ListItemIcon>
<LockIcon />
</ListItemIcon>
<ListItemText primary="Security" />
</ListItem>
)}
<ListItem to='/system/' selected={path.startsWith('/system/')} button component={Link} >
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="System" />
</ListItem>
</List>
</div>
);
const userMenu = (
<div>
<IconButton
ref={this.anchorRef}
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={this.handleToggle}
color="inherit"
>
<AccountCircleIcon />
</IconButton>
<Popper open={authMenuOpen} anchorEl={this.anchorRef.current} transition className={classes.authMenu}>
<ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow">
<CardContent>
<List disablePadding>
<ListItem disableGutters>
<ListItemAvatar>
<Avatar>
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={"Signed in as: " + authenticatedContext.me.username} secondary={authenticatedContext.me.admin ? "Admin User" : undefined} />
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button variant="contained" fullWidth color="primary" onClick={authenticatedContext.signOut}>Sign Out</Button>
</CardActions>
</Card>
</ClickAwayListener>
</Popper>
</div>
);
return (
<div className={classes.root}>
<AppBar position="fixed" className={classes.appBar} elevation={0}>
<Toolbar>
<IconButton
color="inherit"
aria-label="Open drawer"
edge="start"
onClick={this.handleDrawerToggle}
className={classes.menuButton}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" color="inherit" noWrap className={classes.title}>
{sectionTitle}
</Typography>
{features.security && userMenu}
</Toolbar>
</AppBar>
<nav className={classes.drawer}>
<Hidden mdUp implementation="css">
<Drawer
variant="temporary"
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
open={mobileOpen}
onClose={this.handleDrawerToggle}
classes={{
paper: classes.drawerPaper,
}}
ModalProps={{
keepMounted: true,
}}
>
{drawer}
</Drawer>
</Hidden>
<Hidden smDown implementation="css">
<Drawer
classes={{
paper: classes.drawerPaper,
}}
variant="permanent"
open
>
{drawer}
</Drawer>
</Hidden>
</nav>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
}
}
export default withRouter(
withTheme(
withFeatures(
withAuthenticatedContext(
withStyles(styles)(MenuAppBar)
)
)
)
);

View File

@ -0,0 +1,58 @@
import React from 'react';
import { TextValidator, ValidatorComponentProps } from 'react-material-ui-form-validator';
import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import { InputAdornment, IconButton } from '@material-ui/core';
import {Visibility,VisibilityOff } from '@material-ui/icons';
const styles = createStyles({
input: {
"&::-ms-reveal": {
display: "none"
}
}
});
type PasswordValidatorProps = WithStyles<typeof styles> & Exclude<ValidatorComponentProps, "type" | "InputProps">;
interface PasswordValidatorState {
showPassword: boolean;
}
class PasswordValidator extends React.Component<PasswordValidatorProps, PasswordValidatorState> {
state = {
showPassword: false
};
toggleShowPassword = () => {
this.setState({
showPassword: !this.state.showPassword
});
}
render() {
const { classes, ...rest } = this.props;
return (
<TextValidator
{...rest}
type={this.state.showPassword ? 'text' : 'password'}
InputProps={{
classes,
endAdornment:
<InputAdornment position="end">
<IconButton
aria-label="Toggle password visibility"
onClick={this.toggleShowPassword}
>
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}}
/>
);
}
}
export default withStyles(styles)(PasswordValidator);

View File

@ -0,0 +1,113 @@
import React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication';
export interface RestControllerProps<D> extends WithSnackbarProps {
handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void;
saveData: () => void;
loadData: () => void;
data?: D;
loading: boolean;
errorMessage?: string;
}
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => {
switch (event.target.type) {
case "number":
return event.target.valueAsNumber;
case "checkbox":
return event.target.checked;
default:
return event.target.value
}
}
interface RestControllerState<D> {
data?: D;
loading: boolean;
errorMessage?: string;
}
export function restController<D, P extends RestControllerProps<D>>(endpointUrl: string, RestController: React.ComponentType<P & RestControllerProps<D>>) {
return withSnackbar(
class extends React.Component<Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps, RestControllerState<D>> {
state: RestControllerState<D> = {
data: undefined,
loading: false,
errorMessage: undefined
};
setData = (data: D, callback?: () => void) => {
this.setState({
data,
loading: false,
errorMessage: undefined
}, callback);
}
loadData = () => {
this.setState({
data: undefined,
loading: true,
errorMessage: undefined
});
redirectingAuthorizedFetch(endpointUrl).then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
}).then(json => {
this.setState({ data: json, loading: false })
}).catch(error => {
const errorMessage = error.message || "Unknown error";
this.props.enqueueSnackbar("Problem fetching: " + errorMessage, { variant: 'error' });
this.setState({ data: undefined, loading: false, errorMessage });
});
}
saveData = () => {
this.setState({ loading: true });
redirectingAuthorizedFetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(this.state.data),
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.status === 200) {
return response.json();
}
throw Error("Invalid status code: " + response.status);
}).then(json => {
this.props.enqueueSnackbar("Update successful.", { variant: 'success' });
this.setState({ data: json, loading: false });
}).catch(error => {
const errorMessage = error.message || "Unknown error";
this.props.enqueueSnackbar("Problem updating: " + errorMessage, { variant: 'error' });
this.setState({ data: undefined, loading: false, errorMessage });
});
}
handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data });
}
render() {
return <RestController
{...this.state}
{...this.props as P}
handleValueChange={this.handleValueChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
/>;
}
});
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { Button, LinearProgress, Typography } from '@material-ui/core';
import { RestControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: "center"
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
}
})
);
export type RestFormProps<D> = Omit<RestControllerProps<D>, "loading" | "errorMessage"> & { data: D };
interface RestFormLoaderProps<D> extends RestControllerProps<D> {
render: (props: RestFormProps<D>) => JSX.Element;
}
export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
const { loading, errorMessage, loadData, render, data, ...rest } = props;
const classes = useStyles();
if (loading || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading&hellip;
</Typography>
</div>
);
}
if (errorMessage) {
return (
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={loadData}>
Retry
</Button>
</div>
);
}
return render({ ...rest, loadData, data });
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Typography, Paper } from '@material-ui/core';
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
content: {
padding: theme.spacing(2),
margin: theme.spacing(3),
}
})
);
interface SectionContentProps {
title: string;
titleGutter?: boolean;
}
const SectionContent: React.FC<SectionContentProps> = (props) => {
const { children, title, titleGutter } = props;
const classes = useStyles();
return (
<Paper className={classes.content}>
<Typography variant="h6" gutterBottom={titleGutter}>
{title}
</Typography>
{children}
</Paper>
);
};
export default SectionContent;

View File

@ -0,0 +1,96 @@
import React, { FC, Fragment } from 'react';
import { useDropzone, DropzoneState } from 'react-dropzone';
import { makeStyles, createStyles } from '@material-ui/styles';
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
import CancelIcon from '@material-ui/icons/Cancel';
import { Theme, Box, Typography, LinearProgress, Button } from '@material-ui/core';
interface SingleUploadStyleProps extends DropzoneState {
uploading: boolean;
}
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
if (props.isDragAccept) {
return theme.palette.success.main;
}
if (props.isDragReject) {
return theme.palette.error.main;
}
if (props.isDragActive) {
return theme.palette.info.main;
}
return theme.palette.grey[700];
}
const useStyles = makeStyles((theme: Theme) => createStyles({
dropzone: {
padding: theme.spacing(8, 2),
borderWidth: 2,
borderRadius: 2,
borderStyle: 'dashed',
color: theme.palette.grey[700],
transition: 'border .24s ease-in-out',
cursor: (props: SingleUploadStyleProps) => props.uploading ? 'default' : 'pointer',
width: '100%',
borderColor: (props: SingleUploadStyleProps) => getBorderColor(theme, props)
}
}));
export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void;
accept?: string | string[];
uploading: boolean;
progress?: ProgressEvent;
}
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, accept, uploading, progress }) => {
const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false });
const { getRootProps, getInputProps } = dropzoneState;
const classes = useStyles({ ...dropzoneState, uploading });
const renderProgressText = () => {
if (uploading) {
if (progress?.lengthComputable) {
return `Uploading: ${progressPercentage(progress)}%`;
}
return "Uploading\u2026";
}
return "Drop file or click here";
}
const renderProgress = (progress?: ProgressEvent) => (
<LinearProgress
variant={!progress || progress.lengthComputable ? "determinate" : "indeterminate"}
value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
/>
);
return (
<div {...getRootProps({ className: classes.dropzone })}>
<input {...getInputProps()} />
<Box flexDirection="column" display="flex" alignItems="center">
<CloudUploadIcon fontSize='large' />
<Typography variant="h6">
{renderProgressText()}
</Typography>
{uploading && (
<Fragment>
<Box width="100%" p={2}>
{renderProgress(progress)}
</Box>
<Button startIcon={<CancelIcon />} variant="contained" color="secondary" onClick={onCancel}>
Cancel
</Button>
</Fragment>
)}
</Box>
</div>
);
}
export default SingleUpload;

View File

@ -0,0 +1,133 @@
import React from 'react';
import Sockette from 'sockette';
import throttle from 'lodash/throttle';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { addAccessTokenParameter } from '../authentication';
import { extractEventValue } from '.';
export interface WebSocketControllerProps<D> extends WithSnackbarProps {
handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void;
saveData: () => void;
saveDataAndClear(): () => void;
connected: boolean;
data?: D;
}
interface WebSocketControllerState<D> {
ws: Sockette;
connected: boolean;
clientId?: string;
data?: D;
}
enum WebSocketMessageType {
ID = "id",
PAYLOAD = "payload"
}
interface WebSocketIdMessage {
type: typeof WebSocketMessageType.ID;
id: string;
}
interface WebSocketPayloadMessage<D> {
type: typeof WebSocketMessageType.PAYLOAD;
origin_id: string;
payload: D;
}
export type WebSocketMessage<D> = WebSocketIdMessage | WebSocketPayloadMessage<D>;
export function webSocketController<D, P extends WebSocketControllerProps<D>>(wsUrl: string, wsThrottle: number, WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>) {
return withSnackbar(
class extends React.Component<Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps, WebSocketControllerState<D>> {
constructor(props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps) {
super(props);
this.state = {
ws: new Sockette(addAccessTokenParameter(wsUrl), {
onmessage: this.onMessage,
onopen: this.onOpen,
onclose: this.onClose,
}),
connected: false
}
}
componentWillUnmount() {
this.state.ws.close();
}
onMessage = (event: MessageEvent) => {
const rawData = event.data;
if (typeof rawData === 'string' || rawData instanceof String) {
this.handleMessage(JSON.parse(rawData as string) as WebSocketMessage<D>);
}
}
handleMessage = (message: WebSocketMessage<D>) => {
switch (message.type) {
case WebSocketMessageType.ID:
this.setState({ clientId: message.id });
break;
case WebSocketMessageType.PAYLOAD:
const { clientId, data } = this.state;
if (clientId && (!data || clientId !== message.origin_id)) {
this.setState(
{ data: message.payload }
);
}
break;
}
}
onOpen = () => {
this.setState({ connected: true });
}
onClose = () => {
this.setState({ connected: false, clientId: undefined, data: undefined });
}
setData = (data: D, callback?: () => void) => {
this.setState({ data }, callback);
}
saveData = throttle(() => {
const { ws, connected, data } = this.state;
if (connected) {
ws.json(data);
}
}, wsThrottle);
saveDataAndClear = throttle(() => {
const { ws, connected, data } = this.state;
if (connected) {
this.setState({
data: undefined
}, () => ws.json(data));
}
}, wsThrottle);
handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data });
}
render() {
return <WebSocketController
{...this.props as P}
handleValueChange={this.handleValueChange}
setData={this.setData}
saveData={this.saveData}
saveDataAndClear={this.saveDataAndClear}
connected={this.state.connected}
data={this.state.data}
/>;
}
});
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { LinearProgress, Typography } from '@material-ui/core';
import { WebSocketControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5),
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: "center"
}
})
);
export type WebSocketFormProps<D> = Omit<WebSocketControllerProps<D>, "connected"> & { data: D };
interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> {
render: (props: WebSocketFormProps<D>) => JSX.Element;
}
export default function WebSocketFormLoader<D>(props: WebSocketFormLoaderProps<D>) {
const { connected, render, data, ...rest } = props;
const classes = useStyles();
if (!connected || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Connecting to WebSocket...
</Typography>
</div>
);
}
return render({ ...rest, data });
}

View File

@ -0,0 +1,17 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as FormActions } from './FormActions';
export { default as FormButton } from './FormButton';
export { default as HighlightAvatar } from './HighlightAvatar';
export { default as MenuAppBar } from './MenuAppBar';
export { default as PasswordValidator } from './PasswordValidator';
export { default as RestFormLoader } from './RestFormLoader';
export { default as SectionContent } from './SectionContent';
export { default as WebSocketFormLoader } from './WebSocketFormLoader';
export { default as ErrorButton } from './ErrorButton';
export { default as SingleUpload } from './SingleUpload';
export * from './RestFormLoader';
export * from './RestController';
export * from './WebSocketFormLoader';
export * from './WebSocketController';

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Features } from './types';
export interface ApplicationContext {
features: Features;
}
const ApplicationContextDefaultValue = {} as ApplicationContext
export const ApplicationContext = React.createContext(
ApplicationContextDefaultValue
);
export function withAuthenticatedContexApplicationContext<T extends ApplicationContext>(Component: React.ComponentType<T>) {
return class extends React.Component<Omit<T, keyof ApplicationContext>> {
render() {
return (
<ApplicationContext.Consumer>
{authenticatedContext => <Component {...this.props as T} features={authenticatedContext} />}
</ApplicationContext.Consumer>
);
}
};
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Features } from './types';
export interface FeaturesContext {
features: Features;
}
const FeaturesContextDefaultValue = {} as FeaturesContext
export const FeaturesContext = React.createContext(
FeaturesContextDefaultValue
);
export interface WithFeaturesProps {
features: Features;
}
export function withFeatures<T extends WithFeaturesProps>(Component: React.ComponentType<T>) {
return class extends React.Component<Omit<T, keyof WithFeaturesProps>> {
render() {
return (
<FeaturesContext.Consumer>
{featuresContext => <Component {...this.props as T} features={featuresContext.features} />}
</FeaturesContext.Consumer>
);
}
};
}

View File

@ -0,0 +1,61 @@
import React, { Component } from 'react';
import { Features } from './types';
import { FeaturesContext } from './FeaturesContext';
import FullScreenLoading from '../components/FullScreenLoading';
import ApplicationError from '../components/ApplicationError';
import { FEATURES_ENDPOINT } from '../api';
interface FeaturesWrapperState {
features?: Features;
error?: string;
};
class FeaturesWrapper extends Component<{}, FeaturesWrapperState> {
state: FeaturesWrapperState = {};
componentDidMount() {
this.fetchFeaturesDetails();
}
fetchFeaturesDetails = () => {
fetch(FEATURES_ENDPOINT)
.then(response => {
if (response.status === 200) {
return response.json();
} else {
throw Error("Unexpected status code: " + response.status);
}
}).then(features => {
this.setState({ features });
})
.catch(error => {
this.setState({ error: error.message });
});
}
render() {
const { features, error } = this.state;
if (features) {
return (
<FeaturesContext.Provider value={{
features
}}>
{this.props.children}
</FeaturesContext.Provider>
);
}
if (error) {
return (
<ApplicationError error={error} />
);
}
return (
<FullScreenLoading />
);
}
}
export default FeaturesWrapper;

View File

@ -0,0 +1,8 @@
export interface Features {
project: boolean;
security: boolean;
mqtt: boolean;
ntp: boolean;
ota: boolean;
upload_firmware: boolean;
}

5
interface/src/history.ts Normal file
View File

@ -0,0 +1,5 @@
import { createBrowserHistory } from 'history';
export default createBrowserHistory({
/* pass a configuration object here if needed */
})

13
interface/src/index.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import { render } from 'react-dom';
import history from './history';
import { Router } from 'react-router';
import App from './App';
render((
<Router history={history}>
<App/>
</Router>
), document.getElementById("root"))

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
import { Tabs, Tab } from '@material-ui/core';
import { AuthenticatedContextProps, withAuthenticatedContext, AuthenticatedRoute } from '../authentication';
import { MenuAppBar } from '../components';
import MqttStatusController from './MqttStatusController';
import MqttSettingsController from './MqttSettingsController';
type MqttProps = AuthenticatedContextProps & RouteComponentProps;
class Mqtt extends Component<MqttProps> {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path);
};
render() {
const { authenticatedContext } = this.props;
return (
<MenuAppBar sectionTitle="MQTT">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
<Tab value="/mqtt/status" label="MQTT Status" />
<Tab value="/mqtt/settings" label="MQTT Settings" disabled={!authenticatedContext.me.admin} />
</Tabs>
<Switch>
<AuthenticatedRoute exact path="/mqtt/status" component={MqttStatusController} />
<AuthenticatedRoute exact path="/mqtt/settings" component={MqttSettingsController} />
<Redirect to="/mqtt/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticatedContext(Mqtt);

View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
import { MQTT_SETTINGS_ENDPOINT } from '../api';
import MqttSettingsForm from './MqttSettingsForm';
import { MqttSettings } from './types';
type MqttSettingsControllerProps = RestControllerProps<MqttSettings>;
class MqttSettingsController extends Component<MqttSettingsControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="MQTT Settings" titleGutter>
<RestFormLoader
{...this.props}
render={formProps => <MqttSettingsForm {...formProps} />}
/>
</SectionContent>
)
}
}
export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController);

View File

@ -0,0 +1,128 @@
import React from 'react';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { Checkbox, TextField } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save';
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel, PasswordValidator } from '../components';
import { isIP, isHostname, or } from '../validators';
import { MqttSettings } from './types';
type MqttSettingsFormProps = RestFormProps<MqttSettings>;
class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
componentDidMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
}
render() {
const { data, handleValueChange, saveData } = this.props;
return (
<ValidatorForm onSubmit={saveData}>
<BlockFormControlLabel
control={
<Checkbox
checked={data.enabled}
onChange={handleValueChange('enabled')}
value="enabled"
/>
}
label="Enable MQTT?"
/>
<TextValidator
validators={['required', 'isIPOrHostname']}
errorMessages={['Host is required', "Not a valid IP address or hostname"]}
name="host"
label="Host"
fullWidth
variant="outlined"
value={data.host}
onChange={handleValueChange('host')}
margin="normal"
/>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']}
errorMessages={['Port is required', "Must be a number", "Must be greater than 0 ", "Max value is 65535"]}
name="port"
label="Port"
fullWidth
variant="outlined"
value={data.port}
type="number"
onChange={handleValueChange('port')}
margin="normal"
/>
<TextField
name="username"
label="Username"
fullWidth
variant="outlined"
value={data.username}
onChange={handleValueChange('username')}
margin="normal"
/>
<PasswordValidator
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={handleValueChange('password')}
margin="normal"
/>
<TextField
name="client_id"
label="Client ID (optional)"
fullWidth
variant="outlined"
value={data.client_id}
onChange={handleValueChange('client_id')}
margin="normal"
/>
<TextValidator
validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']}
errorMessages={['Keep alive is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]}
name="keep_alive"
label="Keep Alive (seconds)"
fullWidth
variant="outlined"
value={data.keep_alive}
type="number"
onChange={handleValueChange('keep_alive')}
margin="normal"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.clean_session}
onChange={handleValueChange('clean_session')}
value="clean_session"
/>
}
label="Clean Session?"
/>
<TextValidator
validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']}
errorMessages={['Max topic length is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]}
name="max_topic_length"
label="Max Topic Length"
fullWidth
variant="outlined"
value={data.max_topic_length}
type="number"
onChange={handleValueChange('max_topic_length')}
margin="normal"
/>
<FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
Save
</FormButton>
</FormActions>
</ValidatorForm>
);
}
}
export default MqttSettingsForm;

View File

@ -0,0 +1,45 @@
import { Theme } from "@material-ui/core";
import { MqttStatus, MqttDisconnectReason } from "./types";
export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => {
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
}
export const mqttStatus = ({ enabled, connected }: MqttStatus) => {
if (!enabled) {
return "Not enabled";
}
if (connected) {
return "Connected";
}
return "Disconnected";
}
export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return "TCP disconnected";
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return "Unacceptable protocol version";
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return "Client ID rejected";
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return "Server unavailable";
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return "Malformed credentials";
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return "Not authorized";
case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
return "Device out of memory";
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return "Server fingerprint invalid";
default:
return "Unknown"
}
}

View File

@ -0,0 +1,29 @@
import React, { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
import { MQTT_STATUS_ENDPOINT } from '../api';
import MqttStatusForm from './MqttStatusForm';
import { MqttStatus } from './types';
type MqttStatusControllerProps = RestControllerProps<MqttStatus>;
class MqttStatusController extends Component<MqttStatusControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="MQTT Status">
<RestFormLoader
{...this.props}
render={formProps => <MqttStatusForm {...formProps} />}
/>
</SectionContent>
)
}
}
export default restController(MQTT_STATUS_ENDPOINT, MqttStatusController);

View File

@ -0,0 +1,83 @@
import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import RefreshIcon from '@material-ui/icons/Refresh';
import ReportIcon from '@material-ui/icons/Report';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components';
import { mqttStatusHighlight, mqttStatus, disconnectReason } from './MqttStatus';
import { MqttStatus } from './types';
type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme;
class MqttStatusForm extends Component<MqttStatusFormProps> {
renderConnectionStatus() {
const { data } = this.props
if (data.connected) {
return (
<Fragment>
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary="Client ID" secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
);
}
return (
<Fragment>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
);
}
createListItems() {
const { data, theme } = this.props
return (
<Fragment>
<ListItem>
<ListItemAvatar>
<HighlightAvatar color={mqttStatusHighlight(data, theme)}>
<DeviceHubIcon />
</HighlightAvatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={mqttStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && this.renderConnectionStatus()}
</Fragment>
);
}
render() {
return (
<Fragment>
<List>
{this.createListItems()}
</List>
<FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}>
Refresh
</FormButton>
</FormActions>
</Fragment>
);
}
}
export default withTheme(MqttStatusForm);

View File

@ -0,0 +1,29 @@
export enum MqttDisconnectReason {
TCP_DISCONNECTED = 0,
MQTT_UNACCEPTABLE_PROTOCOL_VERSION = 1,
MQTT_IDENTIFIER_REJECTED = 2,
MQTT_SERVER_UNAVAILABLE = 3,
MQTT_MALFORMED_CREDENTIALS = 4,
MQTT_NOT_AUTHORIZED = 5,
ESP8266_NOT_ENOUGH_SPACE = 6,
TLS_BAD_FINGERPRINT = 7
}
export interface MqttStatus {
enabled: boolean;
connected: boolean;
client_id: string;
disconnect_reason: MqttDisconnectReason;
}
export interface MqttSettings {
enabled: boolean;
host: string;
port: number;
username: string;
password: string;
client_id: string;
keep_alive: number;
clean_session: boolean;
max_topic_length: number;
}

View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
import { NTP_SETTINGS_ENDPOINT } from '../api';
import NTPSettingsForm from './NTPSettingsForm';
import { NTPSettings } from './types';
type NTPSettingsControllerProps = RestControllerProps<NTPSettings>;
class NTPSettingsController extends Component<NTPSettingsControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="NTP Settings" titleGutter>
<RestFormLoader
{...this.props}
render={formProps => <NTPSettingsForm {...formProps} />}
/>
</SectionContent>
)
}
}
export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController);

View File

@ -0,0 +1,80 @@
import React from 'react';
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator';
import { Checkbox, MenuItem } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save';
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel } from '../components';
import { isIP, isHostname, or } from '../validators';
import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ';
import { NTPSettings } from './types';
type NTPSettingsFormProps = RestFormProps<NTPSettings>;
class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
componentDidMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
}
changeTimeZone = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { data, setData } = this.props;
setData({
...data,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
});
}
render() {
const { data, handleValueChange, saveData } = this.props;
return (
<ValidatorForm onSubmit={saveData}>
<BlockFormControlLabel
control={
<Checkbox
checked={data.enabled}
onChange={handleValueChange('enabled')}
value="enabled"
/>
}
label="Enable NTP?"
/>
<TextValidator
validators={['required', 'isIPOrHostname']}
errorMessages={['Server is required', "Not a valid IP address or hostname"]}
name="server"
label="Server"
fullWidth
variant="outlined"
value={data.server}
onChange={handleValueChange('server')}
margin="normal"
/>
<SelectValidator
validators={['required']}
errorMessages={['Time zone is required']}
name="tz_label"
label="Time zone"
fullWidth
variant="outlined"
native="true"
value={selectedTimeZone(data.tz_label, data.tz_format)}
onChange={this.changeTimeZone}
margin="normal"
>
<MenuItem disabled>Time zone...</MenuItem>
{timeZoneSelectItems()}
</SelectValidator>
<FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
Save
</FormButton>
</FormActions>
</ValidatorForm>
);
}
}
export default NTPSettingsForm;

View File

@ -0,0 +1,26 @@
import { Theme } from "@material-ui/core";
import { NTPStatus, NTPSyncStatus } from "./types";
export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE;
export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.info.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
}
export const ntpStatus = ({ status }: NTPStatus) => {
switch (status) {
case NTPSyncStatus.NTP_INACTIVE:
return "Inactive";
case NTPSyncStatus.NTP_ACTIVE:
return "Active";
default:
return "Unknown";
}
}

View File

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
import { NTP_STATUS_ENDPOINT } from '../api';
import NTPStatusForm from './NTPStatusForm';
import { NTPStatus } from './types';
type NTPStatusControllerProps = RestControllerProps<NTPStatus>;
class NTPStatusController extends Component<NTPStatusControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="NTP Status">
<RestFormLoader
{...this.props}
render={formProps => <NTPStatusForm {...formProps} />}
/>
</SectionContent>
);
}
}
export default restController(NTP_STATUS_ENDPOINT, NTPStatusController);

View File

@ -0,0 +1,198 @@
import React, { Component, Fragment } from 'react';
import moment from 'moment';
import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText, Button } from '@material-ui/core';
import { Dialog, DialogTitle, DialogContent, DialogActions, Box, TextField } from '@material-ui/core';
import SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle';
import AccessTimeIcon from '@material-ui/icons/AccessTime';
import DNSIcon from '@material-ui/icons/Dns';
import UpdateIcon from '@material-ui/icons/Update';
import AvTimerIcon from '@material-ui/icons/AvTimer';
import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormButton, HighlightAvatar } from '../components';
import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus';
import { formatIsoDateTime, formatLocalDateTime } from './TimeFormat';
import { NTPStatus, Time } from './types';
import { redirectingAuthorizedFetch, withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
import { TIME_ENDPOINT } from '../api';
type NTPStatusFormProps = RestFormProps<NTPStatus> & WithTheme & AuthenticatedContextProps;
interface NTPStatusFormState {
settingTime: boolean;
localTime: string;
processing: boolean;
}
class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
constructor(props: NTPStatusFormProps) {
super(props);
this.state = {
settingTime: false,
localTime: '',
processing: false
};
}
updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ localTime: event.target.value });
}
openSetTime = () => {
this.setState({ localTime: formatLocalDateTime(moment()), settingTime: true, });
}
closeSetTime = () => {
this.setState({ settingTime: false });
}
createAdjustedTime = (): Time => {
const currentLocalTime = moment(this.props.data.time_local);
const newLocalTime = moment(this.state.localTime);
newLocalTime.subtract(currentLocalTime.utcOffset())
newLocalTime.milliseconds(0);
newLocalTime.utc();
return {
time_utc: newLocalTime.format()
}
}
configureTime = () => {
this.setState({ processing: true });
redirectingAuthorizedFetch(TIME_ENDPOINT,
{
method: 'POST',
body: JSON.stringify(this.createAdjustedTime()),
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.status === 200) {
this.props.enqueueSnackbar("Time set successfully", { variant: 'success' });
this.setState({ processing: false, settingTime: false }, this.props.loadData);
} else {
throw Error("Error setting time, status code: " + response.status);
}
})
.catch(error => {
this.props.enqueueSnackbar(error.message || "Problem setting the time", { variant: 'error' });
this.setState({ processing: false, settingTime: false });
});
}
renderSetTimeDialog() {
return (
<Dialog
open={this.state.settingTime}
onClose={this.closeSetTime}
>
<DialogTitle>Set Time</DialogTitle>
<DialogContent dividers>
<Box mb={2}>Enter local date and time below to set the device's time.</Box>
<TextField
label="Local Time"
type="datetime-local"
value={this.state.localTime}
onChange={this.updateLocalTime}
disabled={this.state.processing}
variant="outlined"
fullWidth
InputLabelProps={{
shrink: true,
}}
/>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={this.closeSetTime} color="secondary">
Cancel
</Button>
<Button startIcon={<AccessTimeIcon />} variant="contained" onClick={this.configureTime} disabled={this.state.processing} color="primary" autoFocus>
Set Time
</Button>
</DialogActions>
</Dialog>
)
}
render() {
const { data, theme } = this.props
const me = this.props.authenticatedContext.me;
return (
<Fragment>
<List>
<ListItem>
<ListItemAvatar>
<HighlightAvatar color={ntpStatusHighlight(data, theme)}>
<UpdateIcon />
</HighlightAvatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpActive(data) && (
<Fragment>
<ListItem>
<ListItemAvatar>
<Avatar>
<DNSIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="NTP Server" secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Local Time" secondary={formatIsoDateTime(data.time_local)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="UTC Time" secondary={formatIsoDateTime(data.time_utc)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<AvTimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Uptime" secondary={moment.duration(data.uptime, 'seconds').humanize()} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1} padding={1}>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}>
Refresh
</FormButton>
</Box>
{me.admin && !isNtpActive(data) && (
<Box flexWrap="none" padding={1} whiteSpace="nowrap">
<Button onClick={this.openSetTime} variant="contained" color="primary" startIcon={<AccessTimeIcon />}>
Set Time
</Button>
</Box>
)}
</Box>
{this.renderSetTimeDialog()}
</Fragment>
);
}
}
export default withAuthenticatedContext(withTheme(NTPStatusForm));

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
import { Tabs, Tab } from '@material-ui/core';
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
import { MenuAppBar } from '../components';
import NTPStatusController from './NTPStatusController';
import NTPSettingsController from './NTPSettingsController';
type NetworkTimeProps = AuthenticatedContextProps & RouteComponentProps;
class NetworkTime extends Component<NetworkTimeProps> {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path);
};
render() {
const { authenticatedContext } = this.props;
return (
<MenuAppBar sectionTitle="Network Time">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
<Tab value="/ntp/status" label="NTP Status" />
<Tab value="/ntp/settings" label="NTP Settings" disabled={!authenticatedContext.me.admin} />
</Tabs>
<Switch>
<AuthenticatedRoute exact path="/ntp/status" component={NTPStatusController} />
<AuthenticatedRoute exact path="/ntp/settings" component={NTPSettingsController} />
<Redirect to="/ntp/status" />
</Switch>
</MenuAppBar>
)
}
}
export default withAuthenticatedContext(NetworkTime)

479
interface/src/ntp/TZ.tsx Normal file
View File

@ -0,0 +1,479 @@
import React from 'react';
import MenuItem from '@material-ui/core/MenuItem';
type TimeZones = {
[name: string]: string
};
export const TIME_ZONES: TimeZones = {
"Africa/Abidjan": "GMT0",
"Africa/Accra": "GMT0",
"Africa/Addis_Ababa": "EAT-3",
"Africa/Algiers": "CET-1",
"Africa/Asmara": "EAT-3",
"Africa/Bamako": "GMT0",
"Africa/Bangui": "WAT-1",
"Africa/Banjul": "GMT0",
"Africa/Bissau": "GMT0",
"Africa/Blantyre": "CAT-2",
"Africa/Brazzaville": "WAT-1",
"Africa/Bujumbura": "CAT-2",
"Africa/Cairo": "EET-2",
"Africa/Casablanca": "UNK-1",
"Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Africa/Conakry": "GMT0",
"Africa/Dakar": "GMT0",
"Africa/Dar_es_Salaam": "EAT-3",
"Africa/Djibouti": "EAT-3",
"Africa/Douala": "WAT-1",
"Africa/El_Aaiun": "UNK-1",
"Africa/Freetown": "GMT0",
"Africa/Gaborone": "CAT-2",
"Africa/Harare": "CAT-2",
"Africa/Johannesburg": "SAST-2",
"Africa/Juba": "EAT-3",
"Africa/Kampala": "EAT-3",
"Africa/Khartoum": "CAT-2",
"Africa/Kigali": "CAT-2",
"Africa/Kinshasa": "WAT-1",
"Africa/Lagos": "WAT-1",
"Africa/Libreville": "WAT-1",
"Africa/Lome": "GMT0",
"Africa/Luanda": "WAT-1",
"Africa/Lubumbashi": "CAT-2",
"Africa/Lusaka": "CAT-2",
"Africa/Malabo": "WAT-1",
"Africa/Maputo": "CAT-2",
"Africa/Maseru": "SAST-2",
"Africa/Mbabane": "SAST-2",
"Africa/Mogadishu": "EAT-3",
"Africa/Monrovia": "GMT0",
"Africa/Nairobi": "EAT-3",
"Africa/Ndjamena": "WAT-1",
"Africa/Niamey": "WAT-1",
"Africa/Nouakchott": "GMT0",
"Africa/Ouagadougou": "GMT0",
"Africa/Porto-Novo": "WAT-1",
"Africa/Sao_Tome": "GMT0",
"Africa/Tripoli": "EET-2",
"Africa/Tunis": "CET-1",
"Africa/Windhoek": "CAT-2",
"America/Adak": "HST10HDT,M3.2.0,M11.1.0",
"America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Anguilla": "AST4",
"America/Antigua": "AST4",
"America/Araguaina": "UNK3",
"America/Argentina/Buenos_Aires": "UNK3",
"America/Argentina/Catamarca": "UNK3",
"America/Argentina/Cordoba": "UNK3",
"America/Argentina/Jujuy": "UNK3",
"America/Argentina/La_Rioja": "UNK3",
"America/Argentina/Mendoza": "UNK3",
"America/Argentina/Rio_Gallegos": "UNK3",
"America/Argentina/Salta": "UNK3",
"America/Argentina/San_Juan": "UNK3",
"America/Argentina/San_Luis": "UNK3",
"America/Argentina/Tucuman": "UNK3",
"America/Argentina/Ushuaia": "UNK3",
"America/Aruba": "AST4",
"America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0",
"America/Atikokan": "EST5",
"America/Bahia": "UNK3",
"America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0",
"America/Barbados": "AST4",
"America/Belem": "UNK3",
"America/Belize": "CST6",
"America/Blanc-Sablon": "AST4",
"America/Boa_Vista": "UNK4",
"America/Bogota": "UNK5",
"America/Boise": "MST7MDT,M3.2.0,M11.1.0",
"America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0",
"America/Campo_Grande": "UNK4",
"America/Cancun": "EST5",
"America/Caracas": "UNK4",
"America/Cayenne": "UNK3",
"America/Cayman": "EST5",
"America/Chicago": "CST6CDT,M3.2.0,M11.1.0",
"America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0",
"America/Costa_Rica": "CST6",
"America/Creston": "MST7",
"America/Cuiaba": "UNK4",
"America/Curacao": "AST4",
"America/Danmarkshavn": "GMT0",
"America/Dawson": "MST7",
"America/Dawson_Creek": "MST7",
"America/Denver": "MST7MDT,M3.2.0,M11.1.0",
"America/Detroit": "EST5EDT,M3.2.0,M11.1.0",
"America/Dominica": "AST4",
"America/Edmonton": "MST7MDT,M3.2.0,M11.1.0",
"America/Eirunepe": "UNK5",
"America/El_Salvador": "CST6",
"America/Fort_Nelson": "MST7",
"America/Fortaleza": "UNK3",
"America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1",
"America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0",
"America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0",
"America/Grenada": "AST4",
"America/Guadeloupe": "AST4",
"America/Guatemala": "CST6",
"America/Guayaquil": "UNK5",
"America/Guyana": "UNK4",
"America/Halifax": "AST4ADT,M3.2.0,M11.1.0",
"America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1",
"America/Hermosillo": "MST7",
"America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Knox": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Marengo": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Petersburg": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Tell_City": "CST6CDT,M3.2.0,M11.1.0",
"America/Indiana/Vevay": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Vincennes": "EST5EDT,M3.2.0,M11.1.0",
"America/Indiana/Winamac": "EST5EDT,M3.2.0,M11.1.0",
"America/Inuvik": "MST7MDT,M3.2.0,M11.1.0",
"America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0",
"America/Jamaica": "EST5",
"America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Kentucky/Louisville": "EST5EDT,M3.2.0,M11.1.0",
"America/Kentucky/Monticello": "EST5EDT,M3.2.0,M11.1.0",
"America/Kralendijk": "AST4",
"America/La_Paz": "UNK4",
"America/Lima": "UNK5",
"America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0",
"America/Lower_Princes": "AST4",
"America/Maceio": "UNK3",
"America/Managua": "CST6",
"America/Manaus": "UNK4",
"America/Marigot": "AST4",
"America/Martinique": "AST4",
"America/Matamoros": "CST6CDT,M3.2.0,M11.1.0",
"America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0",
"America/Menominee": "CST6CDT,M3.2.0,M11.1.0",
"America/Merida": "CST6CDT,M4.1.0,M10.5.0",
"America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0",
"America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0",
"America/Moncton": "AST4ADT,M3.2.0,M11.1.0",
"America/Monterrey": "CST6CDT,M4.1.0,M10.5.0",
"America/Montevideo": "UNK3",
"America/Montreal": "EST5EDT,M3.2.0,M11.1.0",
"America/Montserrat": "AST4",
"America/Nassau": "EST5EDT,M3.2.0,M11.1.0",
"America/New_York": "EST5EDT,M3.2.0,M11.1.0",
"America/Nipigon": "EST5EDT,M3.2.0,M11.1.0",
"America/Nome": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Noronha": "UNK2",
"America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/Center": "CST6CDT,M3.2.0,M11.1.0",
"America/North_Dakota/New_Salem": "CST6CDT,M3.2.0,M11.1.0",
"America/Ojinaga": "MST7MDT,M3.2.0,M11.1.0",
"America/Panama": "EST5",
"America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0",
"America/Paramaribo": "UNK3",
"America/Phoenix": "MST7",
"America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0",
"America/Port_of_Spain": "AST4",
"America/Porto_Velho": "UNK4",
"America/Puerto_Rico": "AST4",
"America/Punta_Arenas": "UNK3",
"America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0",
"America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0",
"America/Recife": "UNK3",
"America/Regina": "CST6",
"America/Resolute": "CST6CDT,M3.2.0,M11.1.0",
"America/Rio_Branco": "UNK5",
"America/Santarem": "UNK3",
"America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24",
"America/Santo_Domingo": "AST4",
"America/Sao_Paulo": "UNK3",
"America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0",
"America/St_Barthelemy": "AST4",
"America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0",
"America/St_Kitts": "AST4",
"America/St_Lucia": "AST4",
"America/St_Thomas": "AST4",
"America/St_Vincent": "AST4",
"America/Swift_Current": "CST6",
"America/Tegucigalpa": "CST6",
"America/Thule": "AST4ADT,M3.2.0,M11.1.0",
"America/Thunder_Bay": "EST5EDT,M3.2.0,M11.1.0",
"America/Tijuana": "PST8PDT,M3.2.0,M11.1.0",
"America/Toronto": "EST5EDT,M3.2.0,M11.1.0",
"America/Tortola": "AST4",
"America/Vancouver": "PST8PDT,M3.2.0,M11.1.0",
"America/Whitehorse": "MST7",
"America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0",
"America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0",
"America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0",
"Antarctica/Casey": "UNK-8",
"Antarctica/Davis": "UNK-7",
"Antarctica/DumontDUrville": "UNK-10",
"Antarctica/Macquarie": "UNK-11",
"Antarctica/Mawson": "UNK-5",
"Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Antarctica/Palmer": "UNK3",
"Antarctica/Rothera": "UNK3",
"Antarctica/Syowa": "UNK-3",
"Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3",
"Antarctica/Vostok": "UNK-6",
"Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Asia/Aden": "UNK-3",
"Asia/Almaty": "UNK-6",
"Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1",
"Asia/Anadyr": "UNK-12",
"Asia/Aqtau": "UNK-5",
"Asia/Aqtobe": "UNK-5",
"Asia/Ashgabat": "UNK-5",
"Asia/Atyrau": "UNK-5",
"Asia/Baghdad": "UNK-3",
"Asia/Bahrain": "UNK-3",
"Asia/Baku": "UNK-4",
"Asia/Bangkok": "UNK-7",
"Asia/Barnaul": "UNK-7",
"Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0",
"Asia/Bishkek": "UNK-6",
"Asia/Brunei": "UNK-8",
"Asia/Chita": "UNK-9",
"Asia/Choibalsan": "UNK-8",
"Asia/Colombo": "UNK-5:30",
"Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0",
"Asia/Dhaka": "UNK-6",
"Asia/Dili": "UNK-9",
"Asia/Dubai": "UNK-4",
"Asia/Dushanbe": "UNK-5",
"Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Gaza": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Hebron": "EET-2EEST,M3.5.5/0,M10.5.6/1",
"Asia/Ho_Chi_Minh": "UNK-7",
"Asia/Hong_Kong": "HKT-8",
"Asia/Hovd": "UNK-7",
"Asia/Irkutsk": "UNK-8",
"Asia/Jakarta": "WIB-7",
"Asia/Jayapura": "WIT-9",
"Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0",
"Asia/Kabul": "UNK-4:30",
"Asia/Kamchatka": "UNK-12",
"Asia/Karachi": "PKT-5",
"Asia/Kathmandu": "UNK-5:45",
"Asia/Khandyga": "UNK-9",
"Asia/Kolkata": "IST-5:30",
"Asia/Krasnoyarsk": "UNK-7",
"Asia/Kuala_Lumpur": "UNK-8",
"Asia/Kuching": "UNK-8",
"Asia/Kuwait": "UNK-3",
"Asia/Macau": "CST-8",
"Asia/Magadan": "UNK-11",
"Asia/Makassar": "WITA-8",
"Asia/Manila": "PST-8",
"Asia/Muscat": "UNK-4",
"Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Asia/Novokuznetsk": "UNK-7",
"Asia/Novosibirsk": "UNK-7",
"Asia/Omsk": "UNK-6",
"Asia/Oral": "UNK-5",
"Asia/Phnom_Penh": "UNK-7",
"Asia/Pontianak": "WIB-7",
"Asia/Pyongyang": "KST-9",
"Asia/Qatar": "UNK-3",
"Asia/Qyzylorda": "UNK-5",
"Asia/Riyadh": "UNK-3",
"Asia/Sakhalin": "UNK-11",
"Asia/Samarkand": "UNK-5",
"Asia/Seoul": "KST-9",
"Asia/Shanghai": "CST-8",
"Asia/Singapore": "UNK-8",
"Asia/Srednekolymsk": "UNK-11",
"Asia/Taipei": "CST-8",
"Asia/Tashkent": "UNK-5",
"Asia/Tbilisi": "UNK-4",
"Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24",
"Asia/Thimphu": "UNK-6",
"Asia/Tokyo": "JST-9",
"Asia/Tomsk": "UNK-7",
"Asia/Ulaanbaatar": "UNK-8",
"Asia/Urumqi": "UNK-6",
"Asia/Ust-Nera": "UNK-10",
"Asia/Vientiane": "UNK-7",
"Asia/Vladivostok": "UNK-10",
"Asia/Yakutsk": "UNK-9",
"Asia/Yangon": "UNK-6:30",
"Asia/Yekaterinburg": "UNK-5",
"Asia/Yerevan": "UNK-4",
"Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1",
"Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0",
"Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Cape_Verde": "UNK1",
"Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0",
"Atlantic/Reykjavik": "GMT0",
"Atlantic/South_Georgia": "UNK2",
"Atlantic/St_Helena": "GMT0",
"Atlantic/Stanley": "UNK3",
"Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Brisbane": "AEST-10",
"Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
"Australia/Currie": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Darwin": "ACST-9:30",
"Australia/Eucla": "UNK-8:45",
"Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Lindeman": "AEST-10",
"Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0",
"Australia/Melbourne": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Australia/Perth": "AWST-8",
"Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3",
"Etc/GMT": "GMT0",
"Etc/GMT+0": "GMT0",
"Etc/GMT+1": "UNK1",
"Etc/GMT+10": "UNK10",
"Etc/GMT+11": "UNK11",
"Etc/GMT+12": "UNK12",
"Etc/GMT+2": "UNK2",
"Etc/GMT+3": "UNK3",
"Etc/GMT+4": "UNK4",
"Etc/GMT+5": "UNK5",
"Etc/GMT+6": "UNK6",
"Etc/GMT+7": "UNK7",
"Etc/GMT+8": "UNK8",
"Etc/GMT+9": "UNK9",
"Etc/GMT-0": "GMT0",
"Etc/GMT-1": "UNK-1",
"Etc/GMT-10": "UNK-10",
"Etc/GMT-11": "UNK-11",
"Etc/GMT-12": "UNK-12",
"Etc/GMT-13": "UNK-13",
"Etc/GMT-14": "UNK-14",
"Etc/GMT-2": "UNK-2",
"Etc/GMT-3": "UNK-3",
"Etc/GMT-4": "UNK-4",
"Etc/GMT-5": "UNK-5",
"Etc/GMT-6": "UNK-6",
"Etc/GMT-7": "UNK-7",
"Etc/GMT-8": "UNK-8",
"Etc/GMT-9": "UNK-9",
"Etc/GMT0": "GMT0",
"Etc/Greenwich": "GMT0",
"Etc/UCT": "UTC0",
"Etc/UTC": "UTC0",
"Etc/Universal": "UTC0",
"Etc/Zulu": "UTC0",
"Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Andorra": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Astrakhan": "UNK-4",
"Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Belgrade": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Berlin": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bratislava": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Brussels": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Bucharest": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Busingen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Chisinau": "EET-2EEST,M3.5.0,M10.5.0/3",
"Europe/Copenhagen": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Dublin": "IST-1GMT0,M10.5.0,M3.5.0/1",
"Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Isle_of_Man": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Istanbul": "UNK-3",
"Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Kaliningrad": "EET-2",
"Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Kirov": "UNK-3",
"Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0",
"Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/London": "GMT0BST,M3.5.0/1,M10.5.0",
"Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Madrid": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Malta": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Minsk": "UNK-3",
"Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Moscow": "MSK-3",
"Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Paris": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Podgorica": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Prague": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Riga": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Samara": "UNK-4",
"Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sarajevo": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Saratov": "UNK-4",
"Europe/Simferopol": "MSK-3",
"Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Sofia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Ulyanovsk": "UNK-4",
"Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Vaduz": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vatican": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vienna": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Volgograd": "UNK-4",
"Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zagreb": "CET-1CEST,M3.5.0,M10.5.0/3",
"Europe/Zaporozhye": "EET-2EEST,M3.5.0/3,M10.5.0/4",
"Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3",
"Indian/Antananarivo": "EAT-3",
"Indian/Chagos": "UNK-6",
"Indian/Christmas": "UNK-7",
"Indian/Cocos": "UNK-6:30",
"Indian/Comoro": "EAT-3",
"Indian/Kerguelen": "UNK-5",
"Indian/Mahe": "UNK-4",
"Indian/Maldives": "UNK-5",
"Indian/Mauritius": "UNK-4",
"Indian/Mayotte": "EAT-3",
"Indian/Reunion": "UNK-4",
"Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/4",
"Pacific/Auckland": "NZST-12NZDT,M9.5.0,M4.1.0/3",
"Pacific/Bougainville": "UNK-11",
"Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45",
"Pacific/Chuuk": "UNK-10",
"Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22",
"Pacific/Efate": "UNK-11",
"Pacific/Enderbury": "UNK-13",
"Pacific/Fakaofo": "UNK-13",
"Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99",
"Pacific/Funafuti": "UNK-12",
"Pacific/Galapagos": "UNK6",
"Pacific/Gambier": "UNK9",
"Pacific/Guadalcanal": "UNK-11",
"Pacific/Guam": "ChST-10",
"Pacific/Honolulu": "HST10",
"Pacific/Kiritimati": "UNK-14",
"Pacific/Kosrae": "UNK-11",
"Pacific/Kwajalein": "UNK-12",
"Pacific/Majuro": "UNK-12",
"Pacific/Marquesas": "UNK9:30",
"Pacific/Midway": "SST11",
"Pacific/Nauru": "UNK-12",
"Pacific/Niue": "UNK11",
"Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3",
"Pacific/Noumea": "UNK-11",
"Pacific/Pago_Pago": "SST11",
"Pacific/Palau": "UNK-9",
"Pacific/Pitcairn": "UNK8",
"Pacific/Pohnpei": "UNK-11",
"Pacific/Port_Moresby": "UNK-10",
"Pacific/Rarotonga": "UNK10",
"Pacific/Saipan": "ChST-10",
"Pacific/Tahiti": "UNK10",
"Pacific/Tarawa": "UNK-12",
"Pacific/Tongatapu": "UNK-13",
"Pacific/Wake": "UNK-12",
"Pacific/Wallis": "UNK-12"
}
export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined;
}
export function timeZoneSelectItems() {
return Object.keys(TIME_ZONES).map(label => (
<MenuItem key={label} value={label}>{label}</MenuItem>
));
}

View File

@ -0,0 +1,5 @@
import moment, { Moment } from 'moment';
export const formatIsoDateTime = (isoDateString: string) => moment.parseZone(isoDateString).format('ll @ HH:mm:ss');
export const formatLocalDateTime = (moment: Moment) => moment.format('YYYY-MM-DDTHH:mm');

View File

@ -0,0 +1,23 @@
export enum NTPSyncStatus {
NTP_INACTIVE = 0,
NTP_ACTIVE = 1
}
export interface NTPStatus {
status: NTPSyncStatus;
time_utc: string;
time_local: string;
server: string;
uptime: number;
}
export interface NTPSettings {
enabled: boolean;
server: string;
tz_label: string;
tz_format: string;
}
export interface Time {
time_utc: string;
}

View File

@ -0,0 +1,120 @@
import React, {Component} from 'react';
import {Box, List, ListItem, ListItemText} from '@material-ui/core';
import {
FormButton,
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import {ENDPOINT_ROOT} from "../api";
import {GeneralInformaitonState} from "./types";
import RefreshIcon from "@material-ui/icons/Refresh";
// define api endpoint
export const GENERALINFORMATION_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "generalinfo";
type GeneralInformationRestControllerProps = RestControllerProps<GeneralInformaitonState>;
class GeneralInformation extends Component<GeneralInformationRestControllerProps> {
intervalhandler: number | undefined;
componentDidMount() {
this.props.loadData();
// this.intervalhandler = window.setInterval(() => {
// this.props.loadData();
// console.log("refreshing data");
// console.log(this.props.data)
// }, 10000);
}
componentWillUnmount() {
clearInterval(this.intervalhandler);
}
render() {
return (
<SectionContent title='Information' titleGutter>
<RestFormLoader
{...this.props}
render={props => (
<>
<List>
<ListItem>
<ListItemText
primary="Chip läuft seit:"
secondary={this.stringifyTime(props.data.runtime)}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Zuletzt zu wenig Wasser:"
secondary={props.data.lastWaterOutage !== 0 ? "vor " + this.stringifyTime(props.data.lastWaterOutage) : "noch nie!"}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Letzer Pumpenzyklus"
secondary={props.data.lastpumptime !== 0 ? "vor " + this.stringifyTime(props.data.lastpumptime) : "noch nie!"}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Letze Pumpdauer:"
secondary={props.data.lastPumpDuration !== 0 ? this.stringifyTime(props.data.lastPumpDuration) : "-"}
/>
</ListItem>
<ListItem>
<ListItemText
primary="Temperatur/Luftfeuchtigkeit:"
secondary={(props.data.temp !== -1 ? props.data.temp + "C" : "Auslesefehler!") + " / " + (props.data.hum !== -1 ? props.data.hum + "%" : "Auslesefehler!")}
/>
</ListItem>
<ListItem>
<ListItemText
primary="WasserSensor / DruckSensor"
secondary={(props.data.watersensor ? "EIN" : "AUS!") + " / " + (props.data.pressuresensor ? "EIN" : "AUS")}
/>
</ListItem>
</List>
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1} padding={1}>
<FormButton startIcon={<RefreshIcon/>} variant="contained" color="secondary"
onClick={this.props.loadData}>
Refresh
</FormButton>
</Box>
<Box flexWrap="none" padding={1} whiteSpace="nowrap">
Version: {props.data.version}
</Box>
</Box>
</>
)}
/>
</SectionContent>
);
}
/**
* stringify seconds to a pretty format
* @param sec number of seconds
*/
stringifyTime(sec: number): string {
if (sec >= 86400) {
// display days
return (Math.trunc(sec / 86400) + "d " + Math.trunc((sec % 86400) / 3600) + "h " + Math.trunc((sec % 3600) / 60) + "min " + sec % 60 + "sec");
} else if (sec >= 3600) {
// display hours
return (Math.trunc(sec / 3600) + "h " + Math.trunc((sec % 3600) / 60) + "min " + sec % 60 + "sec");
} else if (sec >= 60) {
// only seconds and minutes
return (Math.trunc(sec / 60) + "min " + sec % 60 + "sec");
} else {
// only seconds
return (sec + "sec");
}
}
}
export default restController(GENERALINFORMATION_SETTINGS_ENDPOINT, GeneralInformation);

View File

@ -0,0 +1,27 @@
import React, { Component } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import {List, ListItem, ListItemIcon, ListItemText} from '@material-ui/core';
import SettingsRemoteIcon from '@material-ui/icons/SettingsRemote';
import { PROJECT_PATH } from '../api';
class ProjectMenu extends Component<RouteComponentProps> {
render() {
const path = this.props.match.url;
return (
<List>
<ListItem to={`/${PROJECT_PATH}/pumpe/`} selected={path.startsWith(`/${PROJECT_PATH}/pumpe/`)} button component={Link}>
<ListItemIcon>
<SettingsRemoteIcon />
</ListItemIcon>
<ListItemText primary="Pumpensteuerung" />
</ListItem>
</List>
)
}
}
export default withRouter(ProjectMenu);

Some files were not shown because too many files have changed in this diff Show More