321 Commits

Author SHA1 Message Date
proddy
50459a23fe force v16 of nodejs 2021-06-26 11:13:07 +02:00
proddy
5bf53c3389 3.1.1 2021-06-26 11:03:03 +02:00
proddy
4b7aa95be3 Merge remote-tracking branch 'origin/dev' 2021-06-26 11:02:55 +02:00
proddy
b5921d15ac show TRACE messages correctly 2021-06-26 10:28:44 +02:00
proddy
82a4f1499a added more screenshots 2021-06-26 10:28:30 +02:00
Proddy
e99c9208ad Update pre_release.yml
on demand
2021-06-19 14:12:32 +02:00
proddy
65dae7af42 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2021-06-19 14:04:17 +02:00
proddy
050c75944a fixes #76 2021-06-19 14:04:09 +02:00
Proddy
2d96aa1736 Update pre_release.yml
typo
2021-06-19 13:32:46 +02:00
proddy
85161ec09a node to v16 2021-06-19 13:32:14 +02:00
proddy
967eee67c4 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2021-06-19 13:32:07 +02:00
proddy
469f78a329 update node to v16 2021-06-19 13:30:40 +02:00
proddy
1e4eb52c90 auto formatting 2021-06-19 13:30:32 +02:00
Proddy
fdbbfe8ddb Update pre_release.yml
node to v16
2021-06-19 13:27:47 +02:00
proddy
e79d4603fc replace TRACE with ALL 2021-06-18 21:42:16 +02:00
proddy
9ef2e62955 update for 3.1.1b7 2021-06-18 20:40:47 +02:00
proddy
c234503a9c Merge remote-tracking branch 'origin/ft_webui_log' into dev 2021-06-18 20:38:28 +02:00
proddy
2056d3ff19 UI updates #71 2021-06-18 17:08:57 +02:00
proddy
270298eb8a text changes 2021-06-17 11:40:19 +02:00
proddy
7e7bd29c9a show # messages, use msgpack to compress json 2021-06-16 20:31:35 +02:00
proddy
19b37d9e0e Show realtime debug log in WebUI #71 2021-06-16 14:54:36 +02:00
proddy
fc2bcd50ca log to webui - initial version 2021-06-14 21:28:20 +02:00
MichaelDvP
37da9d3755 fix #73 RC300 summersetmode 2021-06-14 16:47:44 +02:00
proddy
fffed3b411 comment 2021-06-12 12:41:12 +02:00
proddy
9738c0848d update package 2021-06-12 12:41:06 +02:00
proddy
fc11db03f0 make 'call system' commands work again 2021-06-10 23:11:05 +02:00
proddy
17a28d246d remove old code 2021-06-10 23:10:48 +02:00
proddy
3143ed1060 fix comment 2021-06-10 23:10:40 +02:00
proddy
50540f1f82 add API to test suite 2021-06-10 23:10:26 +02:00
proddy
fad1b09e19 bump to 3.1.1b6 2021-06-08 18:25:00 +02:00
proddy
a1912405c7 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2021-06-08 18:24:16 +02:00
proddy
cc30e09e4b TAG_DEVICE_DATA_WW is only for BOILER 2021-06-08 18:24:06 +02:00
proddy
07a943eedf rename warm water to ww 2021-06-08 18:23:54 +02:00
proddy
adf4584717 remove TAG_DEVICE_DATA_WW 2021-06-08 18:23:37 +02:00
proddy
b3d647850d add ww to those ww values 2021-06-08 18:22:58 +02:00
proddy
ee6a09c9df do not default to info if no command specificied 2021-06-08 18:22:18 +02:00
proddy
a84ae9e7cc fix console commands 2021-06-08 18:21:53 +02:00
MichaelDvP
40206a27ac fix '0 hours' display 2021-06-08 16:07:20 +02:00
MichaelDvP
5c282b7a7e fix charging type 2021-06-08 16:04:41 +02:00
proddy
8dd18aa24d Render values in Web natively #70 2021-06-07 21:19:52 +02:00
proddy
db43f2d711 remove debug log to console 2021-06-07 21:18:48 +02:00
proddy
e9741ea4f8 improve layout, booleans show as a menu 2021-06-07 21:18:29 +02:00
proddy
cf416ee080 fix spelling 2021-06-07 21:17:54 +02:00
proddy
af41f352ba change spelling 2021-06-07 21:17:39 +02:00
proddy
feed65bea6 don't create HA sensor for wifi rssi if only ethernet 2021-06-07 21:15:46 +02:00
proddy
66f14fff82 fix prettier on win10 2021-05-17 09:50:48 +02:00
Proddy
70943f5758 Update pre_release.yml 2021-05-16 15:52:09 +02:00
Proddy
3bc280b817 Delete check_code.yml 2021-05-16 15:51:56 +02:00
proddy
9e432efcd1 remove code check 2021-05-16 15:51:44 +02:00
proddy
8417c715c1 remove local make 2021-05-16 15:50:10 +02:00
proddy
ddb3633fdb test build 2021-05-16 15:41:49 +02:00
Proddy
62b15a5319 Update pre_release.yml 2021-05-16 15:35:06 +02:00
Proddy
8dd18802d6 Update tagged_release.yml 2021-05-16 15:34:45 +02:00
proddy
8530520a62 updated changelog 2021-05-16 15:33:24 +02:00
proddy
b077d867ba fixes prefix ww to the HA entity names #67 2021-05-16 15:27:18 +02:00
proddy
8c48639572 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2021-05-16 13:49:16 +02:00
proddy
4a6ca636e5 add product id to HA, and top of entity list 2021-05-16 13:49:05 +02:00
proddy
4bce819bd3 fix Incorrect Thermostat mode mapping in Home Assistant (RC310) #66 2021-05-16 13:48:48 +02:00
proddy
de63d10e3d add default HA icon for each device 2021-05-16 13:48:23 +02:00
proddy
c62183f886 comment change 2021-05-16 13:47:55 +02:00
proddy
6a73ee4a0b more HA icons 2021-05-16 12:43:33 +02:00
proddy
964db8e7d7 more HA icons 2021-05-16 12:43:24 +02:00
proddy
5b66528c0b rssi shows real rssi and wifistrength shows % in heartbeat 2021-05-16 12:43:07 +02:00
proddy
01fd90f3ed added more HA icons 2021-05-16 12:42:36 +02:00
MichaelDvP
5e7bed1063 fix #68, add hc as prefix to web cmd 2021-05-15 18:27:09 +02:00
proddy
4d69846932 eslint 2021-05-14 12:46:06 +02:00
proddy
fec5ff3132 eslint 2021-05-14 12:45:57 +02:00
proddy
15df0c0552 added lint & standalone as targets 2021-05-14 12:45:49 +02:00
proddy
42a362196e updated doc 2021-05-14 12:45:19 +02:00
proddy
ab28013ec6 removed EMSESP_PLATFORM 2021-05-14 12:45:00 +02:00
proddy
75f3a6f82a added eslint to prettier 2021-05-14 12:44:40 +02:00
proddy
e467e73755 updated tabs and comma 2021-05-14 12:44:18 +02:00
proddy
505e846dd8 added 2021-05-14 12:44:02 +02:00
proddy
7808959d67 added log.h 2021-05-14 12:43:44 +02:00
proddy
1ecee740d3 fix: move showing version to settings. fixes JWT resetting after each version change 2021-05-14 12:43:11 +02:00
proddy
47eaeba373 remove ESP32 2021-05-14 12:41:04 +02:00
proddy
e7dbccabec bump version 2021-05-14 12:40:49 +02:00
MichaelDvP
6ff3d243bd add missing names 2021-05-11 11:03:40 +02:00
MichaelDvP
4027003729 add SM100 values and commands 2021-05-11 08:51:26 +02:00
MichaelDvP
70fd0ad658 fix empty commands '<device>/hcx' 2021-05-11 08:50:32 +02:00
Proddy
94127ad3eb Merge pull request #58 from MichaelDvP/dev_cmd
Command checks and prefixes
2021-05-10 18:26:58 +02:00
MichaelDvP
44734713f1 Merge pull request #57 from MichaelDvP/dev
Solar SM10 module
2021-05-10 17:32:55 +02:00
MichaelDvP
2f0f45f3ec fix build error with debug_flag 2021-05-10 16:16:34 +02:00
MichaelDvP
8641e9d9cb Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2021-05-10 15:48:41 +02:00
MichaelDvP
bc78dd3f50 Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev_cmd 2021-05-10 15:40:14 +02:00
MichaelDvP
a9fca73f2d command default id=-1, command_check, add "warm water" to long names 2021-05-10 15:34:11 +02:00
MichaelDvP
5cccfacbc4 show mqtt-count and sensor-reads in info 2021-05-10 15:28:53 +02:00
proddy
7425d0e095 updated with latest changes 2021-05-10 14:13:41 +02:00
MichaelDvP
28068bdb98 add SM10 solar values and commands 2021-05-10 14:13:37 +02:00
proddy
bc69ca0a9b show freemem in KB, add more HA icons 2021-05-10 14:10:16 +02:00
MichaelDvP
fd9ac28254 do not publish empty messages, free mem 2021-05-10 13:50:18 +02:00
MichaelDvP
312fd85469 prevent multiple reading of long messages with no offset 2021-05-10 13:40:11 +02:00
proddy
57a516a83a updated README and images 2021-05-09 15:13:16 +02:00
proddy
37bee39cea remove 2021-05-09 15:10:55 +02:00
proddy
ed843ba58d updated images 2021-05-09 15:06:57 +02:00
proddy
f8c7da6e0c remove ESP8266 AsyncTCP lib 2021-05-09 15:03:36 +02:00
proddy
e0b1ff1353 add hosted ems-esp.derbyshire.nl 2021-05-09 13:52:46 +02:00
proddy
0f78df517f fix formatting 2021-05-09 11:01:13 +02:00
proddy
e7dae28922 bump version 2021-05-09 11:00:58 +02:00
proddy
dbd3c04d1b remove comment 2021-05-09 10:59:44 +02:00
proddy
e19566ecb8 comment change 2021-05-09 10:59:28 +02:00
proddy
370af11200 include description in the command 2021-05-09 10:59:16 +02:00
proddy
0e67e8311e show commands has a verbose mode 2021-05-09 10:59:03 +02:00
proddy
81f4724d71 added new command called commands 2021-05-09 10:58:44 +02:00
proddy
4a06d328d6 minor comment change 2021-05-09 10:57:48 +02:00
proddy
4dab735dad moved to wiki doc 2021-05-09 10:57:32 +02:00
proddy
ce2fa15554 remove TODO 2021-05-09 10:57:17 +02:00
proddy
c0cb121660 API defauls to hidden command info_short 2021-05-08 18:42:23 +02:00
proddy
efac66835a hidden command info_short 2021-05-08 18:42:04 +02:00
proddy
4a269fd508 added hidden commands, sort commands 2021-05-08 18:41:46 +02:00
proddy
bcef360252 hide ssid from system settings 2021-05-08 16:01:35 +02:00
proddy
bb262ed0df so 'serve' command works for local builds 2021-05-08 15:36:56 +02:00
proddy
039d60abfb use new API path 2021-05-08 15:33:17 +02:00
proddy
c6a40d2125 auto formatting 2021-05-07 10:15:29 +02:00
proddy
d15aa79d18 add prettier to tidy up typescript 2021-05-07 10:15:19 +02:00
proddy
c0d5bd1f05 add examples 2021-05-06 10:58:05 +02:00
proddy
54c2a73d68 auto formatting 2021-05-06 10:17:33 +02:00
proddy
461aa1fd58 updated text about bearer token 2021-05-06 10:17:25 +02:00
proddy
9211d29e17 upgrade to 6.18 2021-05-06 10:10:37 +02:00
proddy
3c1b30a5e4 auto formatting 2021-05-06 10:10:20 +02:00
MichaelDvP
7f52ef8bd8 info shows shortnames, only valid hcs 2021-05-05 18:48:07 +02:00
MichaelDvP
32f477726b allow not only commands in api 2021-05-05 08:04:06 +02:00
MichaelDvP
e9068e702e fix mixer HA-id nested 2021-05-05 08:03:13 +02:00
proddy
e97f6c09e5 Merge remote-tracking branch 'origin/ft_https' into dev 2021-05-04 12:41:47 +02:00
proddy
a57fdaa4b3 Merge remote-tracking branch 'origin/dev' into main 2021-05-04 12:21:51 +02:00
proddy
1ae738016e prep for 3.1.0 2021-05-04 12:19:01 +02:00
proddy
f0e7ede499 merge with latest dev 2021-05-02 22:17:16 +02:00
proddy
a595bde1b8 Merge remote-tracking branch 'origin/dev' into ft_https 2021-05-02 22:17:06 +02:00
proddy
e113ebd298 fetch command also per device type 2021-05-02 22:09:09 +02:00
proddy
5339e0876e feat: Adopt the OpenAPI 3.0 standard for the REST API #50 2021-05-02 22:08:53 +02:00
proddy
101978f713 include fullname in info command 2021-05-02 22:07:49 +02:00
proddy
23218bca7d some minor formatting 2021-05-02 22:07:22 +02:00
proddy
7d0ed2246a auto formatting 2021-05-02 08:32:52 +02:00
proddy
c43fe4f9ae add doc on API spec 2021-05-01 13:19:57 +02:00
MichaelDvP
ee5b1b8c34 add dallassensors to api-info 2021-04-30 15:51:31 +02:00
MichaelDvP
4f98b4bb21 fix remote thermostat roomtemp 2021-04-30 15:15:07 +02:00
MichaelDvP
2b95a0d125 add boiler command selburnpow, update packages 2021-04-29 16:19:43 +02:00
MichaelDvP
87b2a05d39 add CRF200 thermostat flag and no_write 2021-04-27 14:48:36 +02:00
MichaelDvP
44d0b52424 show sent telegram on tx-error 2021-04-27 14:47:55 +02:00
MichaelDvP
de9ff6a3a1 fix heatpump value position 2021-04-27 14:47:12 +02:00
proddy
4a4e5f1890 Merge branch 'dev' into ft_https 2021-04-27 14:41:52 +02:00
MichaelDvP
fcc4831c9f bool value info, dont show command only in mqtt/telnet 2021-04-26 14:51:49 +02:00
MichaelDvP
6f435cbcfd MC110Status to HT3 boilers 2021-04-26 14:51:02 +02:00
MichaelDvP
b01264f701 terminal linebuffers on edit 2021-04-26 14:50:23 +02:00
MichaelDvP
e6e507a470 system_info_id=0 for heartbeat output to API 2021-04-25 17:20:59 +02:00
MichaelDvP
2b60eaf462 add heatpump values #45, circuit "ww" to info 2021-04-25 14:20:39 +02:00
proddy
bf892aa5dc bump version 2021-04-25 09:53:53 +02:00
proddy
1bd834924a auto formatting 2021-04-25 09:53:45 +02:00
Proddy
e854161da9 Merge pull request #49 from MichaelDvP/dev
add min/max to values, boiler flags, ww-prefix, ha-prefix to mqtt
2021-04-25 09:47:07 +02:00
MichaelDvP
018b4af8d3 value_info bool format 2021-04-24 21:09:28 +02:00
MichaelDvP
903696726c do not show hatemp, etc. 2021-04-24 21:05:10 +02:00
MichaelDvP
0a82c28fbf add min/max to values, boiler flags, ww-prefix, ha-prefix to mqtt 2021-04-24 11:41:03 +02:00
MichaelDvP
70d8b6824c id to value_info, alternative prefix to command/value 2021-04-24 10:43:49 +02:00
MichaelDvP
c4e7747fd1 check devicename lowercase 2021-04-24 09:55:33 +02:00
MichaelDvP
661b8791b3 fix errormessage for shell-commands 2021-04-24 09:50:05 +02:00
MichaelDvP
c9a30a23ec API output utf-8 2021-04-24 09:48:42 +02:00
MichaelDvP
28fde37f93 shell reset lineold if edited 2021-04-24 09:48:20 +02:00
MichaelDvP
3797342a93 Fix codecheck complain 2021-04-22 18:45:25 +02:00
MichaelDvP
7faa0d6e65 Typo 2021-04-22 18:38:08 +02:00
MichaelDvP
23455750fa value-info enum as text with list 2021-04-22 18:24:10 +02:00
MichaelDvP
7cabae7ef5 add thermostat remotetemp 2021-04-22 13:51:12 +02:00
MichaelDvP
7baf5c1d9a Add value_info 2021-04-22 13:50:48 +02:00
proddy
36780509a9 larger dialog boxes 2021-04-21 21:17:12 +02:00
proddy
48c3aa7656 auto formatting 2021-04-21 21:17:02 +02:00
proddy
a951ebc3ed feat: add generate token endpoint and ui for generating tokens for users 2021-04-21 21:16:38 +02:00
proddy
8ea48f7c81 don't publish dallas if there are none 2021-04-21 20:37:45 +02:00
MichaelDvP
a633225ad2 Fix #47 Gateway S32 board profile 2021-04-21 08:46:30 +02:00
MichaelDvP
6b327e3ab3 move system commands to main 2021-04-21 07:44:28 +02:00
MichaelDvP
cd43a9feb8 syslog: timestamp to local, add appname 2021-04-21 07:43:37 +02:00
MichaelDvP
cf641476bf More linebuffers to shell 2021-04-21 07:43:03 +02:00
proddy
462a91b122 3.0.3b2 with mockapi & webcommands 2021-04-17 13:12:25 +02:00
proddy
67a8b4eb80 Merge remote-tracking branch 'origin/ft_webcallcmd' into dev 2021-04-17 13:08:04 +02:00
proddy
e59f349a66 increase mqtt payload max size - https://github.com/emsesp/EMS-ESP32/issues/18#issuecomment-821802433 2021-04-17 13:04:44 +02:00
proddy
031f1abd5d right align device/brand 2021-04-17 13:02:31 +02:00
MichaelDvP
73e478c50c Fix syslog level 2021-04-15 17:53:09 +02:00
MichaelDvP
14199ee4ea Fix syslog level 2021-04-15 17:52:13 +02:00
proddy
a9ec926ffb update lockfile ver 2021-04-11 10:31:10 +02:00
Proddy
9f089bad75 back to v5 of compression-webpack-plugin 2021-04-10 21:20:10 +02:00
Proddy
8071fe04bc create-react-app uses webpack 4 so compression-webpack-plugin needs to be locked to version 6.1.1. 2021-04-10 16:18:01 +02:00
Proddy
47a401b66e update npm & typescript 2021-04-10 16:07:10 +02:00
Proddy
ddd2684d60 tooltip color, edit icon color, text changes 2021-04-10 16:06:58 +02:00
Proddy
784ba7fc23 lowercase flowtemp commands 2021-04-10 14:57:05 +02:00
Proddy
4bcc23641a lowercase flowtemp commands 2021-04-10 14:56:51 +02:00
Proddy
dabb48fb61 send mqtt when shower starts 2021-04-10 14:56:42 +02:00
Proddy
9aea9aab50 bump version 2021-04-10 14:56:30 +02:00
Proddy
b4aed240a7 Merge pull request #44 from MichaelDvP/ft_webcallcmd
mqtt-HA-config dynamically
2021-04-10 14:31:33 +02:00
MichaelDvP
015ab649af fix publish_ha_config, and add clear 2021-04-10 13:32:09 +02:00
MichaelDvP
4cac16093f mqtt-HA-config dynamically 2021-04-10 12:29:50 +02:00
MichaelDvP
b77d9d4125 combine commands and values, some extra commands 2021-04-08 15:20:07 +02:00
MichaelDvP
ac26d58b97 allow web commands only for admin 2021-04-08 15:02:11 +02:00
proddy
ed7b2ef4ef update with list of enhancements 2021-04-08 12:16:28 +02:00
Proddy
5fe5750130 rename register_mqtt_cmd to register_cmd and rename some device names 2021-04-07 18:53:14 +02:00
proddy
314fff587c formatting 2021-04-06 18:39:43 +02:00
proddy
8318981f4e formatting 2021-04-06 18:39:32 +02:00
proddy
365e2fdb6b added temp and seltemp (same) 2021-04-06 18:39:18 +02:00
proddy
7e196785d8 only write access in API is enabled 2021-04-06 18:39:03 +02:00
proddy
5ef1c7e3bd more error controls 2021-04-06 18:38:41 +02:00
proddy
11bdff9132 show device name in debug 2021-04-06 18:38:31 +02:00
proddy
060802c8f1 added thermostat temp 2021-04-06 18:38:13 +02:00
Proddy
312aeea39d feat: Call commands from the Web UI #18 2021-04-06 17:39:07 +02:00
proddy
6c41c49866 remove asyncjson.h 2021-04-06 11:21:01 +02:00
proddy
9dbc6d4d8f device values table reformatting 2021-04-06 11:18:44 +02:00
proddy
33c3ef64e9 bump version to 3.0.2b1 2021-04-05 14:04:46 +02:00
proddy
8c1a138621 update 2021-04-05 13:59:58 +02:00
proddy
4f239d035e re-enable shower alert 2021-04-04 13:38:49 +02:00
proddy
7fa93a8de0 MQTT Formatting payload (nested vs single) is a pull-down option 2021-04-04 09:33:04 +02:00
proddy
84e76e2bd7 adjusted network icons 2021-04-04 09:13:53 +02:00
proddy
2021a2e52b fix nodemon for realtime changing of mock values 2021-04-04 09:13:42 +02:00
proddy
e1f777e33a updates 2021-04-03 13:08:25 +02:00
proddy
166f8f6c3a update packages and readme 2021-04-03 12:17:44 +02:00
proddy
3ace3e2b63 remove EMSESP_TEST 2021-04-03 10:56:31 +02:00
proddy
8c52145c7b make all default settings configurable at build 2021-04-03 10:56:18 +02:00
proddy
6e3b496f86 update packages 2021-04-02 11:46:53 +02:00
Proddy
88c8cb424b feat: add remaining mock calls #41 2021-04-01 23:43:50 +02:00
Proddy
74179ab6e9 add mocks for get and posts 2021-04-01 19:30:10 +02:00
proddy
f6fefc9a69 update npm 2021-04-01 18:03:06 +02:00
proddy
601f91e5a7 remove 2021-04-01 18:03:00 +02:00
proddy
d553542206 quick proxy test 2021-04-01 17:49:43 +02:00
MichaelDvP
3bacfc3361 value2enum texts from local_EN 2021-04-01 16:31:56 +02:00
proddy
45a6cd3606 fix: typo 2021-03-31 10:19:59 +02:00
proddy
577017bd0c fix: rx sent -> rx recieved 2021-03-31 10:19:47 +02:00
proddy
9787d1686f style: auto formatting 2021-03-30 17:58:49 +02:00
proddy
108f236874 fix: offline standalone compiling 2021-03-30 17:58:22 +02:00
Proddy
d47fcda0fe Merge pull request #40 from MichaelDvP/dev
Move texts and some other changes
2021-03-30 17:10:58 +02:00
Proddy
5d21ba2648 Merge branch 'dev' into dev 2021-03-30 17:09:52 +02:00
proddy
1b730062b7 3.0.2b0 prep 2021-03-30 16:39:01 +02:00
proddy
4841e42286 Merge remote-tracking branch 'origin/dev' into main 2021-03-30 16:35:18 +02:00
proddy
df1c227f2c fix heartbeat on Ethernet 2021-03-30 15:52:27 +02:00
MichaelDvP
6fb8a4bbe9 changelog, missing macro 2021-03-30 12:46:27 +02:00
MichaelDvP
5c605e15dd move topics/texts to local_EN, some thermostat commands read values 2021-03-30 12:20:43 +02:00
MichaelDvP
9983269662 allow info command from mqtt, publish in topic:response 2021-03-30 12:06:51 +02:00
MichaelDvP
d891c7a325 publish command to trigger device-publishes 2021-03-30 12:05:57 +02:00
proddy
06008fcf6c Merge branch 'dev' of https://github.com/emsesp/EMS-ESP32 into dev 2021-03-30 12:00:16 +02:00
proddy
15c4a3e9a5 don't disable bluetooth or adc for now 2021-03-30 12:00:09 +02:00
proddy
89f1fc8282 remove redundant code 2021-03-30 11:59:43 +02:00
proddy
ca083166a1 show board profile on boot 2021-03-30 11:59:25 +02:00
proddy
ed177396b2 cleaned up pio scripts 2021-03-30 11:59:06 +02:00
proddy
6dd901880e remove redundant code 2021-03-30 11:58:53 +02:00
MichaelDvP
9771ea8f2d allow read commands with length 2021-03-30 11:57:37 +02:00
MichaelDvP
5cf41bdce0 number format for enum-bool 2021-03-30 11:37:31 +02:00
MichaelDvP
f28fafed8d check all commands lower case 2021-03-30 11:31:20 +02:00
MichaelDvP
81e2c31dd3 use native esp32 64bit timer for uptime 2021-03-30 11:27:42 +02:00
MichaelDvP
d9b577d944 fix syslog reboots without eth 2021-03-30 11:12:16 +02:00
proddy
324a6da0d5 add ubadevices, which is 1st telegram sent 2021-03-29 22:26:21 +02:00
proddy
391fecadd0 formatting 2021-03-29 22:26:08 +02:00
proddy
4d0032441f start syslog when network connected 2021-03-29 22:25:44 +02:00
proddy
8e59460845 start sys;log after network to prevent crash on Ethernet 2021-03-29 11:15:07 +02:00
proddy
4b6c676992 text changes 2021-03-29 11:14:49 +02:00
proddy
0237cc1ca4 add comment 2021-03-29 11:14:29 +02:00
proddy
fbef1ca69a remove comment 2021-03-29 11:14:17 +02:00
proddy
3b4bfaa319 remove ESP8266 references 2021-03-28 21:35:30 +02:00
proddy
2b6a986c4a text changes 2021-03-28 21:35:20 +02:00
proddy
494827299c fix MQTT when only on Ethernet 2021-03-28 21:34:40 +02:00
proddy
a920e89ea2 uppercase esp32 2021-03-28 21:33:33 +02:00
proddy
6a4b7a1ac7 if on ethernet, show it's IP and not WiFi 2021-03-28 21:33:16 +02:00
proddy
621c73ab03 move around build defines 2021-03-28 20:09:33 +02:00
proddy
ac7003124e fix ld issue 2021-03-28 20:09:23 +02:00
proddy
4208c3551a fix lint errors in formatting 2021-03-28 16:55:44 +02:00
proddy
1938c93faf rename subscribes to subscribe_format 2021-03-28 16:53:01 +02:00
proddy
2a070ef55f fallback to AP when Ethernet is dropped 2021-03-28 16:28:13 +02:00
proddy
0c17e8deb3 don't process dallas if gpio is 0 2021-03-28 16:27:54 +02:00
proddy
22b4b66cff added logger so external functions can use 2021-03-28 16:27:40 +02:00
proddy
942d062506 formatting, remove wemos from board profile, fix olimex 2021-03-28 16:27:26 +02:00
proddy
7c3b8954fe added info messages for NTP 2021-03-28 16:26:48 +02:00
proddy
bf90056c61 syslog also works when Ethernet connected 2021-03-28 16:26:33 +02:00
proddy
bcd79bc250 formatting 2021-03-28 16:26:18 +02:00
proddy
07c7ef22cf remove wemos mini d1 2021-03-28 16:26:08 +02:00
proddy
6d420662e1 formatting 2021-03-28 16:25:44 +02:00
proddy
fca458687e fix flashing led on ethernet connection 2021-03-27 16:19:12 +01:00
proddy
e34620e1e8 local ip mods 2021-03-27 16:18:41 +01:00
proddy
bcdb49ffff show "quality" next to the line quality % 2021-03-27 16:04:19 +01:00
proddy
ebb71c7724 fix GH URL 2021-03-27 16:03:49 +01:00
proddy
b8dca3db32 mention that blank SSID = disabling wifi 2021-03-27 16:03:39 +01:00
proddy
94d704730f rename network scan to wifi scan 2021-03-27 16:03:13 +01:00
proddy
0c76ed2c4c custom board profile is allowed 2021-03-27 16:02:57 +01:00
proddy
8bac9f687e hide led is default off/false 2021-03-27 16:02:35 +01:00
proddy
56b597d45f update to new version 2021-03-27 16:02:13 +01:00
proddy
96b83e3eb3 fix ems line quality calculation 2021-03-27 16:02:01 +01:00
proddy
e21ad6a6ba re-enable ethernet detection code 2021-03-27 12:48:16 +01:00
proddy
7fe4b99cef fix bug when CUSTOM is chosen as board profile 2021-03-27 12:48:02 +01:00
proddy
0c8dd1d8cf added generic LAN8720 2021-03-27 12:47:46 +01:00
proddy
cafc6103ea board profiles: pre-configured pin layouts #11 2021-03-27 10:30:28 +01:00
proddy
6d3feaf81c MQTT base from std::string to String 2021-03-27 10:30:15 +01:00
proddy
c8d8b50d47 MQTT base from std::string to String 2021-03-27 10:29:47 +01:00
proddy
0c89d90d56 dallas pin fix for mt-et & nodemcu 2021-03-26 22:39:46 +01:00
proddy
d0fc09fc01 snack popups from 5 to 3 seconds 2021-03-26 17:29:54 +01:00
proddy
c8b6d1e69c rename WiFi to Network 2021-03-26 17:29:40 +01:00
proddy
49d719770c remove comment 2021-03-26 17:29:23 +01:00
proddy
c75a1c9e1e added toUpper 2021-03-26 17:29:13 +01:00
proddy
da7b0e9597 feat: board profiles (#11) 2021-03-26 17:29:00 +01:00
proddy
8f1243850f more tests 2021-03-26 17:27:39 +01:00
proddy
b931e282f2 added extra gpio pins to avoid 2021-03-24 07:48:45 +01:00
proddy
66df8031ed use gpio checker. wrong values will cause crash 2021-03-23 22:22:14 +01:00
proddy
966f82e38c gpio checker 2021-03-23 22:21:51 +01:00
proddy
118cbd9224 improve value detection 2021-03-23 22:21:29 +01:00
proddy
def585fa04 bump to b3 2021-03-23 22:21:08 +01:00
proddy
56a3dfd41a on ESP32 no need to use flash strings for MQTT names 2021-03-23 22:20:58 +01:00
proddy
cc0f4c43ae formatting 2021-03-23 22:20:38 +01:00
proddy
c341148009 minor cleanup 2021-03-23 22:19:57 +01:00
MichaelDvP
9089e5d334 mixer IPM add header temperature for unmixed circuits 2021-03-23 15:30:47 +01:00
MichaelDvP
720a82b3da thermostat datetime command only for supported models 2021-03-23 15:30:08 +01:00
MichaelDvP
a83d3a12fb individual subscriptions: resubscribe, show, some system commands 2021-03-23 15:28:50 +01:00
proddy
1dae9f8beb fix LED when connection made 2021-03-22 22:36:31 +01:00
proddy
e25d6e4d0b quit if no valid board profile 2021-03-22 22:33:23 +01:00
proddy
c01c098f7e added more board profiles for ethernet 2021-03-22 22:09:09 +01:00
proddy
fecfe9d791 added text for board profiles 2021-03-22 21:18:22 +01:00
proddy
b996c4dcf6 feat: board profiles (#11) 2021-03-22 21:12:19 +01:00
MichaelDvP
273efbcb65 update changelog 2021-03-22 19:38:17 +01:00
MichaelDvP
7d177ca049 fix compile error standalone? 2021-03-22 19:28:43 +01:00
MichaelDvP
83f46ffd6c add back reset command 2021-03-22 17:28:11 +01:00
MichaelDvP
03e43ba839 add mqtt subscribe settings, thermostat switchtime, boiler heatingsources 2021-03-22 17:17:56 +01:00
MichaelDvP
71dfc0e1eb fix uart out of bounds warning 2021-03-22 17:05:56 +01:00
proddy
355b71cacf minor tidyups 2021-03-22 09:23:57 +01:00
proddy
c660440996 fix: show all devices, except system (#31) 2021-03-22 09:23:34 +01:00
MichaelDvP
70033017fd fix #33, Junkers mode names sorted 2021-03-22 07:37:56 +01:00
proddy
b9c08a58ad fix: only create mqtt subs for Boiler (expose individual commands via MQTT topics #31) 2021-03-21 17:31:52 +01:00
proddy
4db69760c6 fix: rendering of floats 2021-03-21 13:14:05 +01:00
proddy
8ec0731ca2 update upload scripts 2021-03-21 13:02:21 +01:00
proddy
25b1957dbf minor cleanup 2021-03-21 12:48:03 +01:00
proddy
f2dbc26491 feat: expose cmd's via MQTT directly #31 2021-03-21 12:47:47 +01:00
MichaelDvP
fd11a09882 fix negative rounding 2021-03-19 19:49:07 +01:00
420 changed files with 40625 additions and 13219 deletions

View File

@@ -2,7 +2,7 @@ Language: Cpp
BasedOnStyle: LLVM BasedOnStyle: LLVM
UseTab: Never UseTab: Never
IndentWidth: 4 IndentWidth: 4
ColumnLimit: 220 ColumnLimit: 160
TabWidth: 4 TabWidth: 4
#BreakBeforeBraces: Custom #BreakBeforeBraces: Custom
BraceWrapping: BraceWrapping:

View File

@@ -1,37 +0,0 @@
name: Code Check
on:
push:
branches: [ dev ]
pull_request:
branches: [ dev ]
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['cpp']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- run: |
make
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -1,6 +1,7 @@
name: "pre-release" name: "pre-release"
on: on:
workflow_dispatch:
push: push:
branches: branches:
- "dev" - "dev"
@@ -12,50 +13,45 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Checkout source code - name: Get EMS-ESP source code and version
uses: actions/checkout@v2
- name: Get build variables
id: build_info id: build_info
run: | run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'` version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
echo "::set-output name=version::$version" echo "::set-output name=version::$version"
platform=`grep -E '^#define EMSESP_PLATFORM' ./src/version.h | awk -F'"' '{print $2}'`
echo "::set-output name=platform::$platform"
- name: Compile locally - name: Install PlatformIO
run: make
- name: Setup Python
uses: actions/setup-python@v2
- name: Install pio
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -U platformio pip install -U platformio
platformio upgrade platformio upgrade
platformio update platformio update
- name: Build web - name: Build WebUI
run: | run: |
cd interface cd interface
npm install npm ci
npm run build npm run build
- name: Build firmware - name: Build firmware
run: | run: |
platformio run -e ci platformio run -e ci
- name: Release - name: Create a GH Release
id: "automatic_releases" id: "automatic_releases"
uses: "marvinpinto/action-automatic-releases@latest" uses: "marvinpinto/action-automatic-releases@latest"
with: with:
repo_token: "${{ secrets.GITHUB_TOKEN }}" repo_token: "${{ secrets.GITHUB_TOKEN }}"
title: ${{steps.build_info.outputs.platform}} Development Build v${{steps.build_info.outputs.version}} title: ESP32 Development Build v${{steps.build_info.outputs.version}}
automatic_release_tag: "latest" automatic_release_tag: "latest"
prerelease: true prerelease: true
files: | files: |
CHANGELOG_LATEST.md CHANGELOG_LATEST.md
./build/firmware/*.* ./build/firmware/*.*

View File

@@ -12,34 +12,24 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Get build variables - uses: actions/checkout@v2
id: build_info - uses: actions/setup-python@v2
run: | - uses: actions/setup-node@v2
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'` with:
echo "::set-output name=version::$version" node-version: '16'
platform=`grep -E '^#define EMSESP_PLATFORM' ./src/version.h | awk -F'"' '{print $2}'`
echo "::set-output name=platform::$platform"
- name: Compile locally - name: Install PlatformIO
run: make
- name: Setup Python
uses: actions/setup-python@v2
- name: Install pio
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -U platformio pip install -U platformio
platformio upgrade platformio upgrade
platformio update platformio update
- name: Build web - name: Build WebUI
run: | run: |
cd interface cd interface
npm install npm ci
npm run build npm run build
- name: Build firmware - name: Build firmware

2
.gitignore vendored
View File

@@ -25,6 +25,6 @@ emsesp
/data/www /data/www
/lib/framework/WWWData.h /lib/framework/WWWData.h
/interface/build /interface/build
/interface/node_modules node_modules
/interface/.eslintcache /interface/.eslintcache

View File

@@ -5,12 +5,114 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.0] March 18 2021 # [3.1.1] June 26 2021
## **ESP32 version based off ESP-ESP v2.1** ## Changed
- new command called `commands` which lists all available commands. `ems-esp/api/{device}/commands`
- More Home Assistant icons to match the UOMs
- new API. Using secure access tokens and OpenAPI standard. See `doc/EMS-ESP32 API.md` and [#50](https://github.com/emsesp/EMS-ESP32/issues/50)
- show log messages in Web UI [#71](https://github.com/emsesp/EMS-ESP32/issues/71)
## Fixed
- HA thermostat mode was not in sync with actual mode [#66](https://github.com/emsesp/EMS-ESP32/issues/66)
- Don't publish rssi if Wifi is disabled and ethernet is being used
- Booleans are shown as true/false in API GETs
## Changed
- `info` command always shows full names in API. For short names query the device or name directly, e.g. `http://ems-esp/api/boiler`
- free memory is shown in kilobytes
- boiler's warm water entities have ww added to the Home Assistant entity name [#67](https://github.com/emsesp/EMS-ESP32/issues/67)
- improved layout and rendering of device values in the WebUI, also the edit value screen
# [3.1.0] May 4 2021
## Changed
- Mock API to simulate an ESP, for testing web
- Able to write values from the Web UI
- check values with `"cmd":<valuename>` and data empty or `?`
- set hc for values and commands by id or prefix `hc<x>`+separator, separator can be any char
## Fixed
- Don't create Home Assistant MQTT discovery entries for device values that don't exists (#756 on EMS-ESP repo)
- Update shower MQTT when a shower start is detected
- S32 board profile
## Changed
- Icon for Network
- MQTT Formatting payload (nested vs single) is a pull-down option
- moved mqtt-topics and texts to local_EN, all topics lower case
- Re-enabled Shower Alert (still experimental)
- lowercased Flow temp in commands
- system console commands to main
# [3.0.1] March 30 2021
## Added
- power settings, disabling BLE and turning off Wifi sleep
- Rx and Tx counts to Heartbeat MQTT payload
- ethernet support
- id to info command to show only a heatingcircuit
- add sending devices that are not listed to 0x07
- extra MQTT boolean option for "ON" and "OFF"
- support for chunked MQTT payloads to allow large data sets > 2kb
- external Button support (#708) for resetting to factory defaults and other actions
- new console set command in `system`, `set board_profile <profile>` for quickly enabling cabled ethernet connections without using the captive wifi portal
- added in MQTT nested mode, for thermostat and mixer, like we had back in v2
- cascade MC400 (product-id 210) (3.0.0b6), power values for heating sources (3.0.1b1)
- values for wwMaxPower, wwFlowtempOffset
- RC300 `thermostat temp -1` to clear temporary setpoint in auto mode
- syslog port selectable (#744)
- individual mqtt commands (#31)
- board Profiles (#11)
## Fixed
- telegrams matched to masterthermostat 0x18
- multiple roomcontrollers
- readback after write with delay (give ems-devices time to set the value)
- thermostat ES72/RC20 device 66 to command-set RC20_2
- MQTT payloads not adding to queue when MQTT is re-connecting (fixes #369)
- fix for HA topics with invalid command formats (#728)
- wrong position of values #723, #732
- OTA Upload via Web on OSX
- Rx and Tx quality % would sometimes show > 100
## Changed
- changed how telegram parameters are rendered for mqtt, console and web (#632)
- split `show values` in smaller packages (edited)
- extended length of IP/hostname from 32 to 48 chars (#676)
- check flowsensor for `tap_water_active`
- mqtt prefixed with `Base`
- count Dallas sensor fails
- switch from SPIFFS to LITTLEFS
- added ID to MQTT payloads which is the Device's product ID and used in HA to identify a unique HA device
- increased MQTT buffer and reduced wait time between publishes
- updated to the latest ArduinoJson library
- some names of mqtt-tags like in v2.2.1
- new ESP32 partition side to allow for smoother OTA and fallback
- network Gateway IP is optional (#682)emsesp/EMS-ESP
- moved to a new GitHub repo https://github.com/emsesp/EMS-ESP32
- invert LED changed to Hide LED. Default is off.
- renamed Scan Network to Scan WiFi Network
- added version to cmd=settings
- Allow both WiFi and Ethernet together, fall back to AP when Ethernet disconnects
## Removed
- Shower Alert (disabled for now)
# [3.0.0] March 18 2021
## Added
### Added
- Power settings, disabling BLE and turning off Wifi sleep - Power settings, disabling BLE and turning off Wifi sleep
- Rx and Tx counts to Heartbeat MQTT payload - Rx and Tx counts to Heartbeat MQTT payload
- Ethernet support - Ethernet support
@@ -26,7 +128,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- RC300 `thermostat temp -1` to clear temporary setpoint in auto mode - RC300 `thermostat temp -1` to clear temporary setpoint in auto mode
- Syslog port selectable (#744) - Syslog port selectable (#744)
### Fixed ## Fixed
- telegrams matched to masterthermostat 0x18 - telegrams matched to masterthermostat 0x18
- multiple roomcontrollers - multiple roomcontrollers
- readback after write with delay (give ems-devices time to set the value) - readback after write with delay (give ems-devices time to set the value)
@@ -36,7 +139,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- wrong position of values #723, #732 - wrong position of values #723, #732
- OTA Upload via Web on OSX - OTA Upload via Web on OSX
### Changed ## Changed
- changed how telegram parameters are rendered for mqtt, console and web (#632) - changed how telegram parameters are rendered for mqtt, console and web (#632)
- split `show values` in smaller packages (edited) - split `show values` in smaller packages (edited)
- extended length of IP/hostname from 32 to 48 chars (#676) - extended length of IP/hostname from 32 to 48 chars (#676)
@@ -51,4 +155,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- new ESP32 partition side to allow for smoother OTA and fallback - new ESP32 partition side to allow for smoother OTA and fallback
- Network Gateway IP is optional (#682)emsesp/EMS-ESP - Network Gateway IP is optional (#682)emsesp/EMS-ESP
- moved to a new GitHub repo https://github.com/emsesp/EMS-ESP32 - moved to a new GitHub repo https://github.com/emsesp/EMS-ESP32

View File

@@ -1,12 +1,9 @@
# Changelog # Changelog
### Added ## Added
## Fixed
### Fixed ## Changed
### Changed
### Removed
## Removed

123
README.md
View File

@@ -2,16 +2,21 @@
**EMS-ESP** is an open-source firmware for the Espressif ESP8266 and ESP32 microcontroller that communicates with **EMS** (Energy Management System) based equipment from manufacturers like Bosch, Buderus, Nefit, Junkers, Worcester and Sieger. **EMS-ESP** is an open-source firmware for the Espressif ESP8266 and ESP32 microcontroller that communicates with **EMS** (Energy Management System) based equipment from manufacturers like Bosch, Buderus, Nefit, Junkers, Worcester and Sieger.
This is the firmware for the ESP32. This project is the specifically for the ESP32. Compared with the previous ESP8266 (version 2) release it has the following enhancements:
- Ethernet Support
- Pre-configured circuit board layouts
- Supports writing EMS values directly from within Web UI
- Mock API server for faster offline development and testing
- Improved API and MQTT commands
- Improvements to Dallas temperature sensors
- Embedded log tracing in the Web UI
[![version](https://img.shields.io/github/release/emsesp/EMS-ESP32.svg?label=Latest%20Release)](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md) [![version](https://img.shields.io/github/release/emsesp/EMS-ESP32.svg?label=Latest%20Release)](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md)
[![release-date](https://img.shields.io/github/release-date/emsesp/EMS-ESP32.svg?label=Released)](https://github.com/emsesp/EMS-ESP32/commits/main) [![release-date](https://img.shields.io/github/release-date/emsesp/EMS-ESP32.svg?label=Released)](https://github.com/emsesp/EMS-ESP32/commits/main)
[![license](https://img.shields.io/github/license/emsesp/EMS-ESP32.svg)](LICENSE) [![license](https://img.shields.io/github/license/emsesp/EMS-ESP32.svg)](LICENSE)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=emsesp/EMS-ESP32&amp;utm_campaign=Badge_Grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&utm_medium=referral&utm_content=emsesp/EMS-ESP32&utm_campaign=Badge_Grade)
[![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases) [![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases)
[![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/emsesp/EMS-ESP32.svg)](http://isitmaintained.com/project/emsesp/EMS-ESP32 "Average time to resolve an issue")
[![Percentage of issues still open](http://isitmaintained.com/badge/open/emsesp/EMS-ESP32.svg)](http://isitmaintained.com/project/emsesp/EMS-ESP32 "Percentage of issues still open")
<br/>
[![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT) [![chat](https://img.shields.io/discord/816637840644505620.svg?style=flat-square&color=blueviolet)](https://discord.gg/3J3GgnzpyT)
If you like **EMS-ESP**, please give it a star, or fork it and contribute! If you like **EMS-ESP**, please give it a star, or fork it and contribute!
@@ -20,82 +25,104 @@ If you like **EMS-ESP**, please give it a star, or fork it and contribute!
[![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network) [![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network)
[![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2) [![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2)
Note, EMS-ESP requires a small hardware circuit that can convert the EMS bus data to be read by the microcontroller. These can be ordered at https://bbqkees-electronics.nl. Note, EMS-ESP requires a small hardware circuit that can convert the EMS bus data to be read by the microcontroller. These can be ordered at <https://bbqkees-electronics.nl> or contact the contributors that can provide the schematic and designs.
<img src="media/gateway-integration.jpg" width=40%> <img src="media/gateway-integration.jpg" width=40%>
--- ---
## **Features** # **Features**
- Compatible with both ESP8266 and ESP32
- A multi-user secure web interface to change settings and monitor the data - A multi-user secure web interface to change settings and monitor the data
- A console, accessible via Serial and Telnet for more monitoring - A console, accessible via Serial and Telnet for more monitoring
- Native support for Home Assistant via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/) - Native support for Home Assistant via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
- Can run standalone as an independent WiFi Access Point or join an existing WiFi network - Can run standalone as an independent WiFi Access Point or join an existing WiFi network
- Easy first-time configuration via a web Captive Portal - Easy first-time configuration via a web Captive Portal
- Support for more than [70 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways) - Support for more than [80 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways)
## **Demo**
See a live demo [here](https://ems-esp.derbyshire.nl) using fake data. Log in with any username/password.
# **Screenshots**
## Web Interface
| | |
| ---------------------------------- | -------------------------------- |
| <img src="media/web_settings.png"> | <img src="media/web_status.png"> |
| <img src="media/web_devices.png"> | <img src="media/web_mqtt.png"> |
| <img src="media/web_edit.png"> | <img src="media/web_log.png"> |
## Telnet Console
<img src="media/console.png" width=80% height=80%>
## In Home Assistant
<img src="media/ha_lovelace.png" width=80% height=80%>
## **Screenshots** # **Installing**
### Web Interface:
| | |
| --- | --- |
| <img src="media/web_settings.PNG"> | <img src="media/web_status.PNG"> |
| <img src="media/web_devices.PNG"> | <img src="media/web_mqtt.PNG"> |
### Telnet Console:
<img src="media/console.PNG" width=80% height=80%>
### In Home Assistant:
<img src="media/ha_lovelace.PNG" width=80% height=80%>
## **Installing**
Refer to the [official documentation](https://emsesp.github.io/docs) to how to install the firmware and configure it. The documentation is being constantly updated as new features and settings are added. Refer to the [official documentation](https://emsesp.github.io/docs) to how to install the firmware and configure it. The documentation is being constantly updated as new features and settings are added.
You can choose to use an pre-built firmware image or compile the code yourself: You can choose to use an pre-built firmware image or compile the code yourself:
* [Uploading a pre-built firmware build](https://emsesp.github.io/docs/#/Uploading-firmware) - [Uploading a pre-built firmware build](https://emsesp.github.io/docs/#/Uploading-firmware)
* [Building the firmware from source code and flashing manually](https://emsesp.github.io/docs/#/Building-firmware) - [Building the firmware from source code and flashing manually](https://emsesp.github.io/docs/#/Building-firmware)
## **Support Information** # **Support Information**
If you're looking for support on **EMS-ESP** there are some options available: If you're looking for support on **EMS-ESP** there are some options available:
### Documentation ## Documentation
* [Official EMS-ESP Documentation](https://emsesp.github.io/docs): For information on how to build and upload the firmware - [Official EMS-ESP Documentation](https://emsesp.github.io/docs): For information on how to build and upload the firmware
* [FAQ and Troubleshooting](https://emsesp.github.io/docs/#/Troubleshooting): For information on common problems and solutions. See also [BBQKees's wiki](https://bbqkees-electronics.nl/wiki/gateway/troubleshooting.html) - [FAQ and Troubleshooting](https://emsesp.github.io/docs/#/Troubleshooting): For information on common problems and solutions. See also [BBQKees's wiki](https://bbqkees-electronics.nl/wiki/gateway/troubleshooting.html)
### Support Community ## Support Community
* [Discord Server](https://discord.gg/3J3GgnzpyT): For support, troubleshooting and general questions. You have better chances to get fast answers from members of the community - [Discord Server](https://discord.gg/3J3GgnzpyT): For support, troubleshooting and general questions. You have better chances to get fast answers from members of the community
* [Search in Issues](https://github.com/emsesp/EMS-ESP32/issues): You might find an answer to your question by searching current or closed issues - [Search in Issues](https://github.com/emsesp/EMS-ESP32/issues): You might find an answer to your question by searching current or closed issues
### Developer's Community ## Developer's Community
* [Bug Report](https://github.com/emsesp/EMS-ESP32/issues/new?template=bug_report.md): For reporting Bugs - [Bug Report](https://github.com/emsesp/EMS-ESP32/issues/new?template=bug_report.md): For reporting Bugs
* [Feature Request](https://github.com/emsesp/EMS-ESP32/issues/new?template=feature_request.md): For requesting features/functions - [Feature Request](https://github.com/emsesp/EMS-ESP32/issues/new?template=feature_request.md): For requesting features/functions
* [Troubleshooting](https://github.com/emsesp/EMS-ESP32/issues/new?template=questions---troubleshooting.md): As a last resort, you can open new *Troubleshooting & Question* issue on GitHub if the solution could not be found using the other channels. Just remember: the more info you provide the more chances you'll have to get an accurate answer - [Troubleshooting](https://github.com/emsesp/EMS-ESP32/issues/new?template=questions---troubleshooting.md): As a last resort, you can open new _Troubleshooting & Question_ issue on GitHub if the solution could not be found using the other channels. Just remember: the more info you provide the more chances you'll have to get an accurate answer
## **Contributing** # **Contributors ✨**
EMS-ESP is a project originally created and owned by [proddy](https://github.com/proddy). Key contributors are:
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center">
<a href="https://github.com/MichaelDvP"><img src="https://avatars.githubusercontent.com/u/59284019?v=3?s=100" width="100px;" alt=""/><br /><sub><b>MichaelDvP</b></sub></a><br /></a> <a href="https://github.com/emsesp/EMS-ESP/commits?author=MichaelDvP" title="v2 Commits">v2</a>
<a href="https://github.com/emsesp/EMS-ESP32/commits?author=MichaelDvP" title="v3 Commits">v3</a>
</td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
You can also contribute to EMS-ESP by
You can contribute to EMS-ESP by
- providing Pull Requests (Features, Fixes, suggestions) - providing Pull Requests (Features, Fixes, suggestions)
- testing new released features and report issues on your EMS equipment - testing new released features and report issues on your EMS equipment
- contributing to missing [Documentation](https://emsesp.github.io/docs) - contributing to missing [Documentation](https://emsesp.github.io/docs)
## **Credits** # **Libraries used**
A shout out to the people helping EMS-ESP get to where it is today... - [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the framework that provides the core of the Web UI
- **@MichaelDvP** for all his amazing contributions and patience. Specifically for the improved uart library, thermostat and mixer logic. - [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these open source libraries
- **@BBQKees** for his endless testing and building the awesome circuit boards - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for JSON
- **@rjwats** for his [esp8266-react](https://github.com/rjwats/esp8266-react) framework that provides the new Web UI - [AsyncMqttClient](https://github.com/marvinroger/async-mqtt-client) for the MQTT client, with custom modifications from @bertmelis and @proddy
- **@nomis** for his core [console](https://github.com/nomis/mcu-uuid-console), telnet and syslog core libraries - ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
- plus everyone else providing suggestions, PRs and the odd donation that keeps this project open source. Thanks!
## **License** # **License**
This program is licensed under GPL-3.0 This program is licensed under GPL-3.0

View File

@@ -1,10 +1,5 @@
# Change the IP address to that of your ESP device to enable local development of the UI. # 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.
# ESP32 dev # REACT_APP_HTTP_ROOT=http://localhost:3000
REACT_APP_HTTP_ROOT=http://10.10.10.101 # REACT_APP_WEB_SOCKET_ROOT=ws://localhost:3000
REACT_APP_WEB_SOCKET_ROOT=ws://10.10.10.101
# ESP8266 dev
#REACT_APP_HTTP_ROOT=http://10.10.10.140
#REACT_APP_WEB_SOCKET_ROOT=ws://10.10.10.140

3
interface/.env.hosted Normal file
View File

@@ -0,0 +1,3 @@
GENERATE_SOURCEMAP=false
REACT_APP_HOSTED=true

2
interface/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
# don't ever lint node_modules
node_modules

27
interface/.eslintrc Normal file
View File

@@ -0,0 +1,27 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
// 0 = ignore, 1 = warning, 2 = error
"no-console": 0,
"prettier/prettier": ["error", { endOfLine: "auto" }],
"explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-non-null-asserted-optional-chain": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-explicit-any": 0
}
}

6
interface/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "none",
"printWidth": 80
}

View File

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

26681
interface/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,47 @@
{ {
"name": "esp8266-react", "name": "emsesp-react",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@material-ui/core": "^4.11.3", "@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2", "@material-ui/icons": "^4.11.2",
"@msgpack/msgpack": "^2.7.0",
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@types/node": "^12.20.4", "@types/node": "^15.0.1",
"@types/react": "^17.0.3", "@types/react": "^17.0.4",
"@types/react-dom": "^17.0.1", "@types/react-dom": "^17.0.3",
"@types/react-material-ui-form-validator": "^2.1.0", "@types/react-material-ui-form-validator": "^2.1.0",
"@types/react-router": "^5.1.12", "@types/react-router": "^5.1.13",
"@types/react-router-dom": "^5.1.6", "@types/react-router-dom": "^5.1.7",
"compression-webpack-plugin": "^4.0.0", "compression-webpack-plugin": "^5.0.2",
"env-cmd": "^10.1.0",
"express": "^4.17.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime-types": "^2.1.29", "mime-types": "^2.1.30",
"notistack": "^1.0.5", "notistack": "^1.0.6",
"parse-ms": "^2.1.0", "parse-ms": "^3.0.0",
"react": "^17.0.1", "react": "^17.0.2",
"react-dom": "^17.0.1", "react-dom": "^17.0.2",
"react-dropzone": "^11.3.1", "react-dropzone": "^11.3.2",
"react-form-validator-core": "^1.1.1", "react-form-validator-core": "^1.1.1",
"react-material-ui-form-validator": "^2.1.4", "react-material-ui-form-validator": "^2.1.4",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.1", "react-scripts": "4.0.3",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"typescript": "4.0.5", "typescript": "4.2.4",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"scripts": { "scripts": {
"start": "react-app-rewired start", "start": "react-app-rewired start",
"build": "react-app-rewired build", "build": "react-app-rewired build",
"eject": "react-scripts eject" "format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
"build-hosted": "env-cmd -f .env.hosted npm run build",
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
"mock-api": "nodemon --watch ../mock-api ../mock-api/server.js",
"standalone": "npm-run-all -p start mock-api",
"lint": "eslint . --ext .ts,.tsx"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "react-app" "extends": "react-app"
@@ -51,6 +59,13 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^6.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"http-proxy-middleware": "^1.1.1",
"nodemon": "^2.0.7",
"npm-run-all": "^4.1.5",
"prettier": "^2.0.5",
"react-app-rewired": "^2.1.8" "react-app-rewired": "^2.1.8"
} }
} }

View File

@@ -1,19 +1,25 @@
const { resolve, relative, sep } = require('path'); const { resolve, relative, sep } = require('path');
const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs'); const {
readdirSync,
existsSync,
unlinkSync,
readFileSync,
createWriteStream
} = require('fs');
var zlib = require('zlib'); var zlib = require('zlib');
var mime = require('mime-types'); var mime = require('mime-types');
const ARDUINO_INCLUDES = "#include <Arduino.h>\n\n"; const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
function getFilesSync(dir, files = []) { function getFilesSync(dir, files = []) {
readdirSync(dir, { withFileTypes: true }).forEach(entry => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name); const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
getFilesSync(entryPath, files); getFilesSync(entryPath, files);
} else { } else {
files.push(entryPath); files.push(entryPath);
} }
}) });
return files; return files;
} }
@@ -25,13 +31,17 @@ function cleanAndOpen(path) {
if (existsSync(path)) { if (existsSync(path)) {
unlinkSync(path); unlinkSync(path);
} }
return createWriteStream(path, { flags: "w+" }); return createWriteStream(path, { flags: 'w+' });
} }
class ProgmemGenerator { class ProgmemGenerator {
constructor(options = {}) { constructor(options = {}) {
const { outputPath, bytesPerLine = 20, indent = " ", includes = ARDUINO_INCLUDES } = options; const {
outputPath,
bytesPerLine = 20,
indent = ' ',
includes = ARDUINO_INCLUDES
} = options;
this.options = { outputPath, bytesPerLine, indent, includes }; this.options = { outputPath, bytesPerLine, indent, includes };
} }
@@ -41,30 +51,34 @@ class ProgmemGenerator {
(compilation, callback) => { (compilation, callback) => {
const { outputPath, bytesPerLine, indent, includes } = this.options; const { outputPath, bytesPerLine, indent, includes } = this.options;
const fileInfo = []; const fileInfo = [];
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath)); const writeStream = cleanAndOpen(
resolve(compilation.options.context, outputPath)
);
try { try {
const writeIncludes = () => { const writeIncludes = () => {
writeStream.write(includes); writeStream.write(includes);
} };
const writeFile = (relativeFilePath, buffer) => { const writeFile = (relativeFilePath, buffer) => {
const variable = "ESP_REACT_DATA_" + fileInfo.length; const variable = 'ESP_REACT_DATA_' + fileInfo.length;
const mimeType = mime.lookup(relativeFilePath); const mimeType = mime.lookup(relativeFilePath);
var size = 0; var size = 0;
writeStream.write("const uint8_t " + variable + "[] PROGMEM = {"); writeStream.write('const uint8_t ' + variable + '[] PROGMEM = {');
const zipBuffer = zlib.gzipSync(buffer); const zipBuffer = zlib.gzipSync(buffer);
zipBuffer.forEach((b) => { zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) { if (!(size % bytesPerLine)) {
writeStream.write("\n"); writeStream.write('\n');
writeStream.write(indent); writeStream.write(indent);
} }
writeStream.write("0x" + ("00" + b.toString(16).toUpperCase()).substr(-2) + ","); writeStream.write(
'0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ','
);
size++; size++;
}); });
if (size % bytesPerLine) { if (size % bytesPerLine) {
writeStream.write("\n"); writeStream.write('\n');
} }
writeStream.write("};\n\n"); writeStream.write('};\n\n');
fileInfo.push({ fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'), uri: '/' + relativeFilePath.replace(sep, '/'),
mimeType, mimeType,
@@ -84,25 +98,37 @@ class ProgmemGenerator {
// process assets // process assets
const { assets } = compilation; const { assets } = compilation;
Object.keys(assets).forEach((relativeFilePath) => { Object.keys(assets).forEach((relativeFilePath) => {
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source())); writeFile(
relativeFilePath,
coherseToBuffer(assets[relativeFilePath].source())
);
}); });
} };
const generateWWWClass = () => { const generateWWWClass = () => {
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler; return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
class WWWData { class WWWData {
${indent}public: ${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { ${indent.repeat(
${fileInfo.map(file => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`).join('\n')} 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)}} ${indent.repeat(2)}}
}; };
`; `;
} };
const writeWWWClass = () => { const writeWWWClass = () => {
writeStream.write(generateWWWClass()); writeStream.write(generateWWWClass());
} };
writeIncludes(); writeIncludes();
writeFiles(); writeFiles();

View File

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

View File

@@ -3,20 +3,26 @@
font-family: 'Roboto'; font-family: 'Roboto';
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/li.woff2) format('woff2'); src: local('Roboto Light'), local('Roboto-Light'),
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; 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-face {
font-family: 'Roboto'; font-family: 'Roboto';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2'); src: local('Roboto'), local('Roboto-Regular'),
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; 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-face {
font-family: 'Roboto'; font-family: 'Roboto';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/me.woff2) format('woff2'); src: local('Roboto Medium'), local('Roboto-Medium'),
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; 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;
}

View File

@@ -14,7 +14,6 @@ import FeaturesWrapper from './features/FeaturesWrapper';
const unauthorizedRedirect = () => <Redirect to="/" />; const unauthorizedRedirect = () => <Redirect to="/" />;
class App extends Component { class App extends Component {
notistackRef: RefObject<any> = React.createRef(); notistackRef: RefObject<any> = React.createRef();
componentDidMount() { componentDidMount() {
@@ -23,21 +22,29 @@ class App extends Component {
onClickDismiss = (key: string | number | undefined) => () => { onClickDismiss = (key: string | number | undefined) => () => {
this.notistackRef.current.closeSnackbar(key); this.notistackRef.current.closeSnackbar(key);
} };
render() { render() {
return ( return (
<CustomMuiTheme> <CustomMuiTheme>
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} <SnackbarProvider
autoHideDuration={3000}
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={this.notistackRef} ref={this.notistackRef}
action={(key) => ( action={(key) => (
<IconButton onClick={this.onClickDismiss(key)} size="small"> <IconButton onClick={this.onClickDismiss(key)} size="small">
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
)}> )}
>
<FeaturesWrapper> <FeaturesWrapper>
<Switch> <Switch>
<Route exact path="/unauthorized" component={unauthorizedRedirect} /> <Route
exact
path="/unauthorized"
component={unauthorizedRedirect}
/>
<Route component={AppRouting} /> <Route component={AppRouting} />
</Switch> </Switch>
</FeaturesWrapper> </FeaturesWrapper>
@@ -47,4 +54,4 @@ class App extends Component {
} }
} }
export default App export default App;

View File

@@ -19,9 +19,9 @@ import Mqtt from './mqtt/Mqtt';
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext'; import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
import { Features } from './features/types'; import { Features } from './features/types';
export const getDefaultRoute = (features: Features) => features.project ? `/${PROJECT_PATH}/` : "/network/"; export const getDefaultRoute = (features: Features) =>
features.project ? `/${PROJECT_PATH}/` : '/network/';
class AppRouting extends Component<WithFeaturesProps> { class AppRouting extends Component<WithFeaturesProps> {
componentDidMount() { componentDidMount() {
Authentication.clearLoginRedirect(); Authentication.clearLoginRedirect();
} }
@@ -35,9 +35,17 @@ class AppRouting extends Component<WithFeaturesProps> {
<UnauthenticatedRoute exact path="/" component={SignIn} /> <UnauthenticatedRoute exact path="/" component={SignIn} />
)} )}
{features.project && ( {features.project && (
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> <AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/*`}
component={ProjectRouting}
/>
)} )}
<AuthenticatedRoute exact path="/network/*" component={NetworkConnection} /> <AuthenticatedRoute
exact
path="/network/*"
component={NetworkConnection}
/>
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
{features.ntp && ( {features.ntp && (
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
@@ -52,7 +60,7 @@ class AppRouting extends Component<WithFeaturesProps> {
<Redirect to={getDefaultRoute(features)} /> <Redirect to={getDefaultRoute(features)} />
</Switch> </Switch>
</AuthenticationWrapper> </AuthenticationWrapper>
) );
} }
} }

View File

@@ -1,17 +1,21 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { CssBaseline } from '@material-ui/core'; import { CssBaseline } from '@material-ui/core';
import { MuiThemeProvider, createMuiTheme, StylesProvider } from '@material-ui/core/styles'; import {
MuiThemeProvider,
createMuiTheme,
StylesProvider
} from '@material-ui/core/styles';
import { blueGrey, orange, red, green } from '@material-ui/core/colors'; import { blueGrey, orange, red, green } from '@material-ui/core/colors';
const theme = createMuiTheme({ const theme = createMuiTheme({
palette: { palette: {
type: "dark", type: 'dark',
primary: { primary: {
main: '#33bfff', main: '#33bfff'
}, },
secondary: { secondary: {
main: '#3d5afe', main: '#3d5afe'
}, },
info: { info: {
main: blueGrey[500] main: blueGrey[500]
@@ -29,7 +33,6 @@ const theme = createMuiTheme({
}); });
export default class CustomMuiTheme extends Component { export default class CustomMuiTheme extends Component {
render() { render() {
return ( return (
<StylesProvider> <StylesProvider>
@@ -40,5 +43,4 @@ export default class CustomMuiTheme extends Component {
</StylesProvider> </StylesProvider>
); );
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { AP_STATUS_ENDPOINT } from '../api'; import { AP_STATUS_ENDPOINT } from '../api';
import APStatusForm from './APStatusForm'; import APStatusForm from './APStatusForm';
@@ -9,7 +14,6 @@ import { APStatus } from './types';
type APStatusControllerProps = RestControllerProps<APStatus>; type APStatusControllerProps = RestControllerProps<APStatus>;
class APStatusController extends Component<APStatusControllerProps> { class APStatusController extends Component<APStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,10 +23,10 @@ class APStatusController extends Component<APStatusControllerProps> {
<SectionContent title="Access Point Status"> <SectionContent title="Access Point Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <APStatusForm {...formProps} />} render={(formProps) => <APStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }

View File

@@ -1,23 +1,34 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@material-ui/core';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub'; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import ComputerIcon from '@material-ui/icons/Computer'; import ComputerIcon from '@material-ui/icons/Computer';
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; import {
RestFormProps,
FormActions,
FormButton,
HighlightAvatar
} from '../components';
import { apStatusHighlight, apStatus } from './APStatus'; import { apStatusHighlight, apStatus } from './APStatus';
import { APStatus } from './types'; import { APStatus } from './types';
type APStatusFormProps = RestFormProps<APStatus> & WithTheme; type APStatusFormProps = RestFormProps<APStatus> & WithTheme;
class APStatusForm extends Component<APStatusFormProps> { class APStatusForm extends Component<APStatusFormProps> {
createListItems() { createListItems() {
const { data, theme } = this.props const { data, theme } = this.props;
return ( return (
<Fragment> <Fragment>
<ListItem> <ListItem>
@@ -61,18 +72,20 @@ class APStatusForm extends Component<APStatusFormProps> {
render() { render() {
return ( return (
<Fragment> <Fragment>
<List> <List>{this.createListItems()}</List>
{this.createListItems()}
</List>
<FormActions> <FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>
</Fragment> </Fragment>
); );
} }
} }
export default withTheme(APStatusForm); export default withTheme(APStatusForm);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,9 @@ export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_SEARCH); getStorage().removeItem(SIGN_IN_SEARCH);
} }
export function fetchLoginRedirect(features: Features): H.LocationDescriptorObject { export function fetchLoginRedirect(
features: Features
): H.LocationDescriptorObject {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME); const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH); const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect(); clearLoginRedirect();
@@ -38,43 +40,51 @@ export function fetchLoginRedirect(features: Features): H.LocationDescriptorObje
} }
/** /**
* Wraps the normal fetch routene with one with provides the access token if present. * Wraps the normal fetch routine with one with provides the access token if present.
*/ */
export function authorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> { export function authorizedFetch(
url: RequestInfo,
params?: RequestInit
): Promise<Response> {
const accessToken = getStorage().getItem(ACCESS_TOKEN); const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
params = params || {}; params = params || {};
params.credentials = 'include'; params.credentials = 'include';
params.headers = { params.headers = {
...params.headers, ...params.headers,
"Authorization": 'Bearer ' + accessToken Authorization: 'Bearer ' + accessToken
}; };
} }
return fetch(url, params); return fetch(url, params);
} }
/** /**
* fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request * 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 * for a single file upload and takes care of adding the Authorization header and redirecting on
* authroization errors as we do for normal fetch operations. * authorization errors as we do for normal fetch operations.
*/ */
export function redirectingAuthorizedUpload(xhr: XMLHttpRequest, url: string, file: File, onProgress: (event: ProgressEvent<EventTarget>) => void): Promise<void> { export function redirectingAuthorizedUpload(
xhr: XMLHttpRequest,
url: string,
file: File,
onProgress: (event: ProgressEvent<EventTarget>) => void
): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
xhr.open("POST", url, true); xhr.open('POST', url, true);
const accessToken = getStorage().getItem(ACCESS_TOKEN); const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.setRequestHeader("Authorization", 'Bearer ' + accessToken); xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
} }
xhr.upload.onprogress = onProgress; xhr.upload.onprogress = onProgress;
xhr.onload = function () { xhr.onload = function () {
if (xhr.status === 401 || xhr.status === 403) { if (xhr.status === 401 || xhr.status === 403) {
history.push("/unauthorized"); history.push('/unauthorized');
} else { } else {
resolve(); resolve();
} }
}; };
xhr.onerror = function (event: ProgressEvent<EventTarget>) { xhr.onerror = function () {
reject(new DOMException('Error', 'UploadError')); reject(new DOMException('Error', 'UploadError'));
}; };
xhr.onabort = function () { xhr.onabort = function () {
@@ -87,19 +97,24 @@ export function redirectingAuthorizedUpload(xhr: XMLHttpRequest, url: string, fi
} }
/** /**
* Wraps the normal fetch routene which redirects on 401 response. * Wraps the normal fetch routine which redirects on 401 response.
*/ */
export function redirectingAuthorizedFetch(url: RequestInfo, params?: RequestInit): Promise<Response> { export function redirectingAuthorizedFetch(
url: RequestInfo,
params?: RequestInit
): Promise<Response> {
return new Promise<Response>((resolve, reject) => { return new Promise<Response>((resolve, reject) => {
authorizedFetch(url, params).then(response => { authorizedFetch(url, params)
if (response.status === 401 || response.status === 403) { .then((response) => {
history.push("/unauthorized"); if (response.status === 401 || response.status === 403) {
} else { history.push('/unauthorized');
resolve(response); } else {
} resolve(response);
}).catch(error => { }
reject(error); })
}); .catch((error) => {
reject(error);
});
}); });
} }

View File

@@ -1,9 +1,8 @@
import * as React from "react"; import * as React from 'react';
export interface Me { export interface Me {
username: string; username: string;
admin: boolean; admin: boolean;
version: string; // proddy added
} }
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
@@ -13,7 +12,7 @@ export interface AuthenticationContextValue {
me?: Me; me?: Me;
} }
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
export const AuthenticationContext = React.createContext( export const AuthenticationContext = React.createContext(
AuthenticationContextDefaultValue AuthenticationContextDefaultValue
); );
@@ -22,12 +21,21 @@ export interface AuthenticationContextProps {
authenticationContext: AuthenticationContextValue; authenticationContext: AuthenticationContextValue;
} }
export function withAuthenticationContext<T extends AuthenticationContextProps>(Component: React.ComponentType<T>) { export function withAuthenticationContext<T extends AuthenticationContextProps>(
return class extends React.Component<Omit<T, keyof AuthenticationContextProps>> { Component: React.ComponentType<T>
) {
return class extends React.Component<
Omit<T, keyof AuthenticationContextProps>
> {
render() { render() {
return ( return (
<AuthenticationContext.Consumer> <AuthenticationContext.Consumer>
{authenticationContext => <Component {...this.props as T} authenticationContext={authenticationContext} />} {(authenticationContext) => (
<Component
{...(this.props as T)}
authenticationContext={authenticationContext}
/>
)}
</AuthenticationContext.Consumer> </AuthenticationContext.Consumer>
); );
} }
@@ -38,7 +46,7 @@ export interface AuthenticatedContextValue extends AuthenticationContextValue {
me: Me; me: Me;
} }
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
export const AuthenticatedContext = React.createContext( export const AuthenticatedContext = React.createContext(
AuthenticatedContextDefaultValue AuthenticatedContextDefaultValue
); );
@@ -47,14 +55,23 @@ export interface AuthenticatedContextProps {
authenticatedContext: AuthenticatedContextValue; authenticatedContext: AuthenticatedContextValue;
} }
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(Component: React.ComponentType<T>) { export function withAuthenticatedContext<T extends AuthenticatedContextProps>(
return class extends React.Component<Omit<T, keyof AuthenticatedContextProps>> { Component: React.ComponentType<T>
) {
return class extends React.Component<
Omit<T, keyof AuthenticatedContextProps>
> {
render() { render() {
return ( return (
<AuthenticatedContext.Consumer> <AuthenticatedContext.Consumer>
{authenticatedContext => <Component {...this.props as T} authenticatedContext={authenticatedContext} />} {(authenticatedContext) => (
<Component
{...(this.props as T)}
authenticatedContext={authenticatedContext}
/>
)}
</AuthenticatedContext.Consumer> </AuthenticatedContext.Consumer>
); );
} }
}; };
} }

View File

@@ -2,14 +2,19 @@ import * as React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
import history from '../history' import history from '../history';
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api'; import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication'; import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
import { AuthenticationContext, AuthenticationContextValue, Me } from './AuthenticationContext'; import {
AuthenticationContext,
AuthenticationContextValue,
Me
} from './AuthenticationContext';
import FullScreenLoading from '../components/FullScreenLoading'; import FullScreenLoading from '../components/FullScreenLoading';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext'; import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me; export const decodeMeJWT = (accessToken: string): Me =>
jwtDecode(accessToken) as Me;
interface AuthenticationWrapperState { interface AuthenticationWrapperState {
context: AuthenticationContextValue; context: AuthenticationContextValue;
@@ -18,15 +23,17 @@ interface AuthenticationWrapperState {
type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps; type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> { class AuthenticationWrapper extends React.Component<
AuthenticationWrapperProps,
AuthenticationWrapperState
> {
constructor(props: AuthenticationWrapperProps) { constructor(props: AuthenticationWrapperProps) {
super(props); super(props);
this.state = { this.state = {
context: { context: {
refresh: this.refresh, refresh: this.refresh,
signIn: this.signIn, signIn: this.signIn,
signOut: this.signOut, signOut: this.signOut
}, },
initialized: false initialized: false
}; };
@@ -39,7 +46,9 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
render() { render() {
return ( return (
<React.Fragment> <React.Fragment>
{this.state.initialized ? this.renderContent() : this.renderContentLoading()} {this.state.initialized
? this.renderContent()
: this.renderContentLoading()}
</React.Fragment> </React.Fragment>
); );
} }
@@ -53,9 +62,7 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
} }
renderContentLoading() { renderContentLoading() {
return ( return <FullScreenLoading />;
<FullScreenLoading />
);
} }
refresh = () => { refresh = () => {
@@ -64,34 +71,53 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
// this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } }); // this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
// return; // return;
// } // }
const accessToken = getStorage().getItem(ACCESS_TOKEN) const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT) authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
.then(response => { .then((response) => {
const me = response.status === 200 ? decodeMeJWT(accessToken) : undefined; const me =
this.setState({ initialized: true, context: { ...this.state.context, me } }); response.status === 200 ? decodeMeJWT(accessToken) : undefined;
}).catch(error => { this.setState({
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); initialized: true,
this.props.enqueueSnackbar("Error verifying authorization: " + error.message, { context: { ...this.state.context, me }
variant: 'error',
}); });
})
.catch((error) => {
this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
this.props.enqueueSnackbar(
'Error verifying authorization: ' + error.message,
{
variant: 'error'
}
);
}); });
} else { } else {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
} }
} };
signIn = (accessToken: string) => { signIn = (accessToken: string) => {
try { try {
getStorage().setItem(ACCESS_TOKEN, accessToken); getStorage().setItem(ACCESS_TOKEN, accessToken);
const me: Me = decodeMeJWT(accessToken); const me: Me = decodeMeJWT(accessToken);
this.setState({ context: { ...this.state.context, me } }); this.setState({ context: { ...this.state.context, me } });
this.props.enqueueSnackbar(`Logged in as ${me.username}`, { variant: 'success' }); this.props.enqueueSnackbar(`Logged in as ${me.username}`, {
variant: 'success'
});
} catch (err) { } catch (err) {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); this.setState({
throw new Error("Failed to parse JWT " + err.message); initialized: true,
context: { ...this.state.context, me: undefined }
});
throw new Error('Failed to parse JWT ' + err.message);
} }
} };
signOut = () => { signOut = () => {
getStorage().removeItem(ACCESS_TOKEN); getStorage().removeItem(ACCESS_TOKEN);
@@ -101,10 +127,9 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
me: undefined me: undefined
} }
}); });
this.props.enqueueSnackbar("You have signed out.", { variant: 'success', }); this.props.enqueueSnackbar('You have signed out', { variant: 'success' });
history.push('/'); history.push('/');
} };
} }
export default withFeatures(withSnackbar(AuthenticationWrapper)) export default withFeatures(withSnackbar(AuthenticationWrapper));

View File

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

View File

@@ -3,4 +3,4 @@ export { default as AuthenticationWrapper } from './AuthenticationWrapper';
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute'; export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
export * from './Authentication'; export * from './Authentication';
export * from './AuthenticationContext'; export * from './AuthenticationContext';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,41 @@
import React, { RefObject, Fragment } from 'react'; import React, { RefObject, Fragment } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { Drawer, AppBar, Toolbar, Avatar, Divider, Button, Box, IconButton } from '@material-ui/core'; import {
import { ClickAwayListener, Popper, Hidden, Typography } from '@material-ui/core'; Drawer,
import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core'; 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 { Card, CardContent, CardActions } from '@material-ui/core';
import { withStyles, createStyles, Theme, WithTheme, WithStyles, withTheme } from '@material-ui/core/styles'; import {
withStyles,
createStyles,
Theme,
WithTheme,
WithStyles,
withTheme
} from '@material-ui/core/styles';
import WifiIcon from '@material-ui/icons/Wifi'; import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet';
import SettingsIcon from '@material-ui/icons/Settings'; import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime'; import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle'; import AccountCircleIcon from '@material-ui/icons/AccountCircle';
@@ -19,76 +46,84 @@ import MenuIcon from '@material-ui/icons/Menu';
import ProjectMenu from '../project/ProjectMenu'; import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../api'; import { PROJECT_NAME } from '../api';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext'; import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
const drawerWidth = 290; const drawerWidth = 290;
const styles = (theme: Theme) => createStyles({ const styles = (theme: Theme) =>
root: { createStyles({
display: 'flex', root: {
}, display: 'flex'
drawer: {
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0,
}, },
}, drawer: {
title: { [theme.breakpoints.up('md')]: {
flexGrow: 1 width: drawerWidth,
}, flexShrink: 0
appBar: { }
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`,
}, },
}, title: {
toolbarImage: { flexGrow: 1
[theme.breakpoints.up('xs')]: {
height: 24,
marginRight: theme.spacing(2)
}, },
[theme.breakpoints.up('sm')]: { appBar: {
height: 36, marginLeft: drawerWidth,
marginRight: theme.spacing(3) [theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`
}
}, },
}, toolbarImage: {
menuButton: { [theme.breakpoints.up('xs')]: {
marginRight: theme.spacing(2), height: 24,
[theme.breakpoints.up('md')]: { marginRight: theme.spacing(2)
display: 'none', },
[theme.breakpoints.up('sm')]: {
height: 36,
marginRight: theme.spacing(3)
}
}, },
}, menuButton: {
toolbar: theme.mixins.toolbar, marginRight: theme.spacing(2),
drawerPaper: { [theme.breakpoints.up('md')]: {
width: drawerWidth, display: 'none'
}, }
content: { },
flexGrow: 1 toolbar: theme.mixins.toolbar,
}, drawerPaper: {
authMenu: { width: drawerWidth
zIndex: theme.zIndex.tooltip, },
maxWidth: 400, content: {
}, flexGrow: 1
authMenuActions: { },
padding: theme.spacing(2), authMenu: {
"& > * + *": { zIndex: theme.zIndex.tooltip,
marginLeft: theme.spacing(2), maxWidth: 400
},
authMenuActions: {
padding: theme.spacing(2),
'& > * + *': {
marginLeft: theme.spacing(2)
}
} }
}, });
});
interface MenuAppBarState { interface MenuAppBarState {
mobileOpen: boolean; mobileOpen: boolean;
authMenuOpen: boolean; authMenuOpen: boolean;
} }
interface MenuAppBarProps extends WithFeaturesProps, AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps { interface MenuAppBarProps
extends WithFeaturesProps,
AuthenticatedContextProps,
WithTheme,
WithStyles<typeof styles>,
RouteComponentProps {
sectionTitle: string; sectionTitle: string;
} }
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> { class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
constructor(props: MenuAppBarProps) { constructor(props: MenuAppBarProps) {
super(props); super(props);
this.state = { this.state = {
@@ -101,38 +136,48 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
handleToggle = () => { handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen }); this.setState({ authMenuOpen: !this.state.authMenuOpen });
} };
handleClose = (event: React.MouseEvent<Document>) => { handleClose = (event: React.MouseEvent<Document>) => {
if (this.anchorRef.current && this.anchorRef.current.contains(event.currentTarget)) { if (
this.anchorRef.current &&
this.anchorRef.current.contains(event.currentTarget)
) {
return; return;
} }
this.setState({ authMenuOpen: false }); this.setState({ authMenuOpen: false });
} };
handleDrawerToggle = () => { handleDrawerToggle = () => {
this.setState({ mobileOpen: !this.state.mobileOpen }); this.setState({ mobileOpen: !this.state.mobileOpen });
}; };
render() { render() {
const { classes, theme, children, sectionTitle, authenticatedContext, features } = this.props; const {
classes,
theme,
children,
sectionTitle,
authenticatedContext,
features
} = this.props;
const { mobileOpen, authMenuOpen } = this.state; const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url; const path = this.props.match.url;
const drawer = ( const drawer = (
<div> <div>
<Toolbar> <Toolbar>
<Box display="flex"> <Box display="flex">
<img src="/app/icon.png" className={classes.toolbarImage} alt={PROJECT_NAME} /> <img
src="/app/icon.png"
className={classes.toolbarImage}
alt={PROJECT_NAME}
/>
</Box> </Box>
<Typography variant="h6" color="textPrimary"> <Typography variant="h6" color="textPrimary">
{PROJECT_NAME} {PROJECT_NAME}
</Typography> </Typography>
<Typography align="right" variant="caption" color="textPrimary">
&nbsp;&nbsp;v{authenticatedContext.me.version}
</Typography>
<Divider absolute /> <Divider absolute />
</Toolbar> </Toolbar>
@@ -142,22 +187,37 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
<Divider /> <Divider />
</Fragment> </Fragment>
)} )}
<List> <List>
<ListItem to='/network/' selected={path.startsWith('/network/')} button component={Link}> <ListItem
to="/network/"
selected={path.startsWith('/network/')}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<WifiIcon /> <SettingsEthernetIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Network Connection" /> <ListItemText primary="Network Connection" />
</ListItem> </ListItem>
<ListItem to='/ap/' selected={path.startsWith('/ap/')} button component={Link}> <ListItem
to="/ap/"
selected={path.startsWith('/ap/')}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<SettingsInputAntennaIcon /> <SettingsInputAntennaIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Access Point" /> <ListItemText primary="Access Point" />
</ListItem> </ListItem>
{features.ntp && ( {features.ntp && (
<ListItem to='/ntp/' selected={path.startsWith('/ntp/')} button component={Link}> <ListItem
to="/ntp/"
selected={path.startsWith('/ntp/')}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<AccessTimeIcon /> <AccessTimeIcon />
</ListItemIcon> </ListItemIcon>
@@ -165,7 +225,12 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
</ListItem> </ListItem>
)} )}
{features.mqtt && ( {features.mqtt && (
<ListItem to='/mqtt/' selected={path.startsWith('/mqtt/')} button component={Link}> <ListItem
to="/mqtt/"
selected={path.startsWith('/mqtt/')}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<DeviceHubIcon /> <DeviceHubIcon />
</ListItemIcon> </ListItemIcon>
@@ -173,14 +238,25 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
</ListItem> </ListItem>
)} )}
{features.security && ( {features.security && (
<ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}> <ListItem
to="/security/"
selected={path.startsWith('/security/')}
button
component={Link}
disabled={!authenticatedContext.me.admin}
>
<ListItemIcon> <ListItemIcon>
<LockIcon /> <LockIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Security" /> <ListItemText primary="Security" />
</ListItem> </ListItem>
)} )}
<ListItem to='/system/' selected={path.startsWith('/system/')} button component={Link} > <ListItem
to="/system/"
selected={path.startsWith('/system/')}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<SettingsIcon /> <SettingsIcon />
</ListItemIcon> </ListItemIcon>
@@ -201,7 +277,12 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
> >
<AccountCircleIcon /> <AccountCircleIcon />
</IconButton> </IconButton>
<Popper open={authMenuOpen} anchorEl={this.anchorRef.current} transition className={classes.authMenu}> <Popper
open={authMenuOpen}
anchorEl={this.anchorRef.current}
transition
className={classes.authMenu}
>
<ClickAwayListener onClickAway={this.handleClose}> <ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow"> <Card id="menu-list-grow">
<CardContent> <CardContent>
@@ -212,13 +293,27 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
<AccountCircleIcon /> <AccountCircleIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={"Signed in as: " + authenticatedContext.me.username} secondary={authenticatedContext.me.admin ? "Admin User" : undefined} /> <ListItemText
primary={
'Signed in as: ' + authenticatedContext.me.username
}
secondary={
authenticatedContext.me.admin ? 'Admin User' : undefined
}
/>
</ListItem> </ListItem>
</List> </List>
</CardContent> </CardContent>
<Divider /> <Divider />
<CardActions className={classes.authMenuActions}> <CardActions className={classes.authMenuActions}>
<Button variant="contained" fullWidth color="primary" onClick={authenticatedContext.signOut}>Sign Out</Button> <Button
variant="contained"
fullWidth
color="primary"
onClick={authenticatedContext.signOut}
>
Sign Out
</Button>
</CardActions> </CardActions>
</Card> </Card>
</ClickAwayListener> </ClickAwayListener>
@@ -239,7 +334,12 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6" color="inherit" noWrap className={classes.title}> <Typography
variant="h6"
color="inherit"
noWrap
className={classes.title}
>
{sectionTitle} {sectionTitle}
</Typography> </Typography>
{features.security && userMenu} {features.security && userMenu}
@@ -253,10 +353,10 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
open={mobileOpen} open={mobileOpen}
onClose={this.handleDrawerToggle} onClose={this.handleDrawerToggle}
classes={{ classes={{
paper: classes.drawerPaper, paper: classes.drawerPaper
}} }}
ModalProps={{ ModalProps={{
keepMounted: true, keepMounted: true
}} }}
> >
{drawer} {drawer}
@@ -265,7 +365,7 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
<Hidden smDown implementation="css"> <Hidden smDown implementation="css">
<Drawer <Drawer
classes={{ classes={{
paper: classes.drawerPaper, paper: classes.drawerPaper
}} }}
variant="permanent" variant="permanent"
open open
@@ -285,10 +385,6 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
export default withRouter( export default withRouter(
withTheme( withTheme(
withFeatures( withFeatures(withAuthenticatedContext(withStyles(styles)(MenuAppBar)))
withAuthenticatedContext(
withStyles(styles)(MenuAppBar)
)
)
) )
); );

View File

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

View File

@@ -4,7 +4,9 @@ import { withSnackbar, WithSnackbarProps } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication'; import { redirectingAuthorizedFetch } from '../authentication';
export interface RestControllerProps<D> extends WithSnackbarProps { export interface RestControllerProps<D> extends WithSnackbarProps {
handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void; handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void; setData: (data: D, callback?: () => void) => void;
saveData: () => void; saveData: () => void;
@@ -15,16 +17,18 @@ export interface RestControllerProps<D> extends WithSnackbarProps {
errorMessage?: string; errorMessage?: string;
} }
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => { export const extractEventValue = (
event: React.ChangeEvent<HTMLInputElement>
) => {
switch (event.target.type) { switch (event.target.type) {
case "number": case 'number':
return event.target.valueAsNumber; return event.target.valueAsNumber;
case "checkbox": case 'checkbox':
return event.target.checked; return event.target.checked;
default: default:
return event.target.value return event.target.value;
} }
} };
interface RestControllerState<D> { interface RestControllerState<D> {
data?: D; data?: D;
@@ -32,10 +36,15 @@ interface RestControllerState<D> {
errorMessage?: string; errorMessage?: string;
} }
export function restController<D, P extends RestControllerProps<D>>(endpointUrl: string, RestController: React.ComponentType<P & RestControllerProps<D>>) { export function restController<D, P extends RestControllerProps<D>>(
endpointUrl: string,
RestController: React.ComponentType<P & RestControllerProps<D>>
) {
return withSnackbar( return withSnackbar(
class extends React.Component<Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps, RestControllerState<D>> { class extends React.Component<
Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps,
RestControllerState<D>
> {
state: RestControllerState<D> = { state: RestControllerState<D> = {
data: undefined, data: undefined,
loading: false, loading: false,
@@ -43,12 +52,15 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
}; };
setData = (data: D, callback?: () => void) => { setData = (data: D, callback?: () => void) => {
this.setState({ this.setState(
data, {
loading: false, data,
errorMessage: undefined loading: false,
}, callback); errorMessage: undefined
} },
callback
);
};
loadData = () => { loadData = () => {
this.setState({ this.setState({
@@ -56,19 +68,24 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
loading: true, loading: true,
errorMessage: undefined errorMessage: undefined
}); });
redirectingAuthorizedFetch(endpointUrl).then(response => { redirectingAuthorizedFetch(endpointUrl)
if (response.status === 200) { .then((response) => {
return response.json(); if (response.status === 200) {
} return response.json();
throw Error("Invalid status code: " + response.status); }
}).then(json => { throw Error('Invalid status code: ' + response.status);
this.setState({ data: json, loading: false }) })
}).catch(error => { .then((json) => {
const errorMessage = error.message || "Unknown error"; this.setState({ data: json, loading: false });
this.props.enqueueSnackbar("Problem fetching: " + errorMessage, { variant: 'error' }); })
this.setState({ data: undefined, loading: false, errorMessage }); .catch((error) => {
}); const errorMessage = error.message || 'Unknown error';
} this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
variant: 'error'
});
this.setState({ data: undefined, loading: false, errorMessage });
});
};
saveData = () => { saveData = () => {
this.setState({ loading: true }); this.setState({ loading: true });
@@ -78,36 +95,47 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}).then(response => { })
if (response.status === 200) { .then((response) => {
return response.json(); if (response.status === 200) {
} return response.json();
throw Error("Invalid status code: " + response.status); }
}).then(json => { throw Error('Invalid status code: ' + response.status);
this.props.enqueueSnackbar("Update successful.", { variant: 'success' }); })
this.setState({ data: json, loading: false }); .then((json) => {
}).catch(error => { this.props.enqueueSnackbar('Update successful.', {
const errorMessage = error.message || "Unknown error"; variant: 'success'
this.props.enqueueSnackbar("Problem updating: " + errorMessage, { variant: 'error' }); });
this.setState({ data: undefined, loading: false, errorMessage }); 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>) => { handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) }; const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data }); this.setState({ data });
} };
render() { render() {
return <RestController return (
{...this.state} <RestController
{...this.props as P} {...this.state}
handleValueChange={this.handleValueChange} {...(this.props as P)}
setData={this.setData} handleValueChange={this.handleValueChange}
saveData={this.saveData} setData={this.setData}
loadData={this.loadData} saveData={this.saveData}
/>; loadData={this.loadData}
/>
);
} }
}
}); );
} }

View File

@@ -8,20 +8,23 @@ import { RestControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
loadingSettings: { loadingSettings: {
margin: theme.spacing(0.5), margin: theme.spacing(0.5)
}, },
loadingSettingsDetails: { loadingSettingsDetails: {
margin: theme.spacing(4), margin: theme.spacing(4),
textAlign: "center" textAlign: 'center'
}, },
button: { button: {
marginRight: theme.spacing(2), marginRight: theme.spacing(2),
marginTop: theme.spacing(2), marginTop: theme.spacing(2)
} }
}) })
); );
export type RestFormProps<D> = Omit<RestControllerProps<D>, "loading" | "errorMessage"> & { data: D }; export type RestFormProps<D> = Omit<
RestControllerProps<D>,
'loading' | 'errorMessage'
> & { data: D };
interface RestFormLoaderProps<D> extends RestControllerProps<D> { interface RestFormLoaderProps<D> extends RestControllerProps<D> {
render: (props: RestFormProps<D>) => JSX.Element; render: (props: RestFormProps<D>) => JSX.Element;
@@ -46,7 +49,12 @@ export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
<Typography variant="h6" className={classes.loadingSettingsDetails}> <Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage} {errorMessage}
</Typography> </Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={loadData}> <Button
variant="contained"
color="secondary"
className={classes.button}
onClick={loadData}
>
Retry Retry
</Button> </Button>
</div> </div>

View File

@@ -7,7 +7,7 @@ const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
content: { content: {
padding: theme.spacing(2), padding: theme.spacing(2),
margin: theme.spacing(3), margin: theme.spacing(3)
} }
}) })
); );
@@ -15,13 +15,14 @@ const useStyles = makeStyles((theme: Theme) =>
interface SectionContentProps { interface SectionContentProps {
title: string; title: string;
titleGutter?: boolean; titleGutter?: boolean;
id?: string;
} }
const SectionContent: React.FC<SectionContentProps> = (props) => { const SectionContent: React.FC<SectionContentProps> = (props) => {
const { children, title, titleGutter } = props; const { children, title, titleGutter, id } = props;
const classes = useStyles(); const classes = useStyles();
return ( return (
<Paper className={classes.content}> <Paper id={id} className={classes.content}>
<Typography variant="h6" gutterBottom={titleGutter}> <Typography variant="h6" gutterBottom={titleGutter}>
{title} {title}
</Typography> </Typography>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { useLayoutEffect, useState } from 'react';
export function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
}

View File

@@ -15,3 +15,5 @@ export * from './RestController';
export * from './WebSocketFormLoader'; export * from './WebSocketFormLoader';
export * from './WebSocketController'; export * from './WebSocketController';
export * from './WindowSize';

View File

@@ -5,21 +5,26 @@ export interface FeaturesContextValue {
features: Features; features: Features;
} }
const FeaturesContextDefaultValue = {} as FeaturesContextValue const FeaturesContextDefaultValue = {} as FeaturesContextValue;
export const FeaturesContext = React.createContext( export const FeaturesContext = React.createContext(FeaturesContextDefaultValue);
FeaturesContextDefaultValue
);
export interface WithFeaturesProps { export interface WithFeaturesProps {
features: Features; features: Features;
} }
export function withFeatures<T extends WithFeaturesProps>(Component: React.ComponentType<T>) { export function withFeatures<T extends WithFeaturesProps>(
Component: React.ComponentType<T>
) {
return class extends React.Component<Omit<T, keyof WithFeaturesProps>> { return class extends React.Component<Omit<T, keyof WithFeaturesProps>> {
render() { render() {
return ( return (
<FeaturesContext.Consumer> <FeaturesContext.Consumer>
{featuresContext => <Component {...this.props as T} features={featuresContext.features} />} {(featuresContext) => (
<Component
{...(this.props as T)}
features={featuresContext.features}
/>
)}
</FeaturesContext.Consumer> </FeaturesContext.Consumer>
); );
} }

View File

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

View File

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

View File

@@ -6,8 +6,9 @@ import { Router } from 'react-router';
import App from './App'; import App from './App';
render(( render(
<Router history={history}> <Router history={history}>
<App/> <App />
</Router> </Router>,
), document.getElementById("root")) document.getElementById('root')
);

View File

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

View File

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

View File

@@ -1,11 +1,21 @@
import React from 'react'; import React from 'react';
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator'; import {
TextValidator,
ValidatorForm,
SelectValidator
} from 'react-material-ui-form-validator';
import { Checkbox, TextField, Typography } from '@material-ui/core'; import { Checkbox, TextField, Typography } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import MenuItem from '@material-ui/core/MenuItem'; import MenuItem from '@material-ui/core/MenuItem';
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel, PasswordValidator } from '../components'; import {
RestFormProps,
FormActions,
FormButton,
BlockFormControlLabel,
PasswordValidator
} from '../components';
import { isIP, isHostname, or, isPath } from '../validators'; import { isIP, isHostname, or, isPath } from '../validators';
import { MqttSettings } from './types'; import { MqttSettings } from './types';
@@ -13,7 +23,6 @@ import { MqttSettings } from './types';
type MqttSettingsFormProps = RestFormProps<MqttSettings>; type MqttSettingsFormProps = RestFormProps<MqttSettings>;
class MqttSettingsForm extends React.Component<MqttSettingsFormProps> { class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
ValidatorForm.addValidationRule('isPath', isPath); ValidatorForm.addValidationRule('isPath', isPath);
@@ -35,7 +44,10 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
/> />
<TextValidator <TextValidator
validators={['required', 'isIPOrHostname']} validators={['required', 'isIPOrHostname']}
errorMessages={['Host is required', "Not a valid IP address or hostname"]} errorMessages={[
'Host is required',
'Not a valid IP address or hostname'
]}
name="host" name="host"
label="Host" label="Host"
fullWidth fullWidth
@@ -45,8 +57,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Port is required', "Must be a number", "Must be greater than 0 ", "Max value is 65535"]} '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" name="port"
label="Port" label="Port"
fullWidth fullWidth
@@ -58,7 +80,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
/> />
<TextValidator <TextValidator
validators={['required', 'isPath']} validators={['required', 'isPath']}
errorMessages={['Base is required', "Not a valid Path"]} errorMessages={['Base is required', 'Not a valid Path']}
name="base" name="base"
label="Base" label="Base"
fullWidth fullWidth
@@ -95,8 +117,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']} validators={[
errorMessages={['Keep alive is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]} '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" name="keep_alive"
label="Keep Alive (seconds)" label="Keep Alive (seconds)"
fullWidth fullWidth
@@ -106,13 +138,15 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
onChange={handleValueChange('keep_alive')} onChange={handleValueChange('keep_alive')}
margin="normal" margin="normal"
/> />
<SelectValidator name="mqtt_qos" <SelectValidator
name="mqtt_qos"
label="QoS" label="QoS"
value={data.mqtt_qos} value={data.mqtt_qos}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange('mqtt_qos')} onChange={handleValueChange('mqtt_qos')}
margin="normal"> margin="normal"
>
<MenuItem value={0}>0 (default)</MenuItem> <MenuItem value={0}>0 (default)</MenuItem>
<MenuItem value={1}>1</MenuItem> <MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem> <MenuItem value={2}>2</MenuItem>
@@ -138,41 +172,60 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
label="Retain Flag" label="Retain Flag"
/> />
<br></br> <br></br>
<Typography variant="h6" color="primary" > <Typography variant="h6" color="primary">
Formatting Formatting
</Typography> </Typography>
<BlockFormControlLabel <SelectValidator
control={ name="nested_format"
<Checkbox label="Topic/Payload Format"
checked={data.nested_format} value={data.nested_format}
onChange={handleValueChange('nested_format')} fullWidth
value="nested_format" variant="outlined"
/> onChange={handleValueChange('nested_format')}
} margin="normal"
label="Nested format (Thermostat & Mixer only)" >
/> <MenuItem value={1}>nested on a single topic</MenuItem>
<SelectValidator name="dallas_format" <MenuItem value={2}>as individual topics</MenuItem>
</SelectValidator>
<SelectValidator
name="dallas_format"
label="Dallas Sensor Payload Grouping" label="Dallas Sensor Payload Grouping"
value={data.dallas_format} value={data.dallas_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange('dallas_format')} onChange={handleValueChange('dallas_format')}
margin="normal"> margin="normal"
>
<MenuItem value={1}>by Sensor ID</MenuItem> <MenuItem value={1}>by Sensor ID</MenuItem>
<MenuItem value={2}>by Number</MenuItem> <MenuItem value={2}>by Number</MenuItem>
</SelectValidator> </SelectValidator>
<SelectValidator name="bool_format" <SelectValidator
name="bool_format"
label="Boolean Format" label="Boolean Format"
value={data.bool_format} value={data.bool_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange('bool_format')} onChange={handleValueChange('bool_format')}
margin="normal"> margin="normal"
>
<MenuItem value={1}>"on"/"off"</MenuItem> <MenuItem value={1}>"on"/"off"</MenuItem>
<MenuItem value={2}>true/false</MenuItem> <MenuItem value={2}>true/false</MenuItem>
<MenuItem value={3}>1/0</MenuItem> <MenuItem value={3}>1/0</MenuItem>
<MenuItem value={4}>"ON"/"OFF"</MenuItem> <MenuItem value={4}>"ON"/"OFF"</MenuItem>
</SelectValidator> </SelectValidator>
<SelectValidator
name="subscribe_format"
label="Subscribe Format"
value={data.subscribe_format}
fullWidth
variant="outlined"
onChange={handleValueChange('subscribe_format')}
margin="normal"
>
<MenuItem value={0}>general device topic</MenuItem>
<MenuItem value={1}>individual topics, main heating circuit</MenuItem>
<MenuItem value={2}>individual topics, all heating circuits</MenuItem>
</SelectValidator>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -181,28 +234,40 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value="ha_enabled" value="ha_enabled"
/> />
} }
label="Home Assistant MQTT Discovery" label="Use Home Assistant MQTT Discovery"
/> />
{ data.ha_enabled && {data.ha_enabled && (
<SelectValidator name="ha_climate_format" <SelectValidator
name="ha_climate_format"
label="Thermostat Room Temperature" label="Thermostat Room Temperature"
value={data.ha_climate_format} value={data.ha_climate_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange('ha_climate_format')} onChange={handleValueChange('ha_climate_format')}
margin="normal"> margin="normal"
>
<MenuItem value={1}>use Current temperature (default)</MenuItem> <MenuItem value={1}>use Current temperature (default)</MenuItem>
<MenuItem value={2}>use Setpoint temperature</MenuItem> <MenuItem value={2}>use Setpoint temperature</MenuItem>
<MenuItem value={3}>Fix to 0</MenuItem> <MenuItem value={3}>Fix to 0</MenuItem>
</SelectValidator> </SelectValidator>
} )}
<br></br> <br></br>
<Typography variant="h6" color="primary" > <Typography variant="h6" color="primary">
Publish Intervals Publish Intervals
</Typography> </Typography>
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Publish time is required', "Must be a number", "Must be 0 or greater", "Max value is 65535"]} 'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Publish time is required',
'Must be a number',
'Must be 0 or greater',
'Max value is 65535'
]}
name="publish_time_boiler" name="publish_time_boiler"
label="Boiler Publish Interval (seconds, 0=on change)" label="Boiler Publish Interval (seconds, 0=on change)"
fullWidth fullWidth
@@ -213,8 +278,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Publish time is required', "Must be a number", "Must be 0 or greater", "Max value is 65535"]} 'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Publish time is required',
'Must be a number',
'Must be 0 or greater',
'Max value is 65535'
]}
name="publish_time_thermostat" name="publish_time_thermostat"
label="Thermostat Publish Interval (seconds, 0=on change)" label="Thermostat Publish Interval (seconds, 0=on change)"
fullWidth fullWidth
@@ -225,8 +300,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Publish time is required', "Must be a number", "Must be 0 or greater", "Max value is 65535"]} 'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Publish time is required',
'Must be a number',
'Must be 0 or greater',
'Max value is 65535'
]}
name="publish_time_solar" name="publish_time_solar"
label="Solar Publish Interval (seconds, 0=on change)" label="Solar Publish Interval (seconds, 0=on change)"
fullWidth fullWidth
@@ -237,8 +322,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Publish time is required', "Must be a number", "Must be 0 or greater", "Max value is 65535"]} 'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Publish time is required',
'Must be a number',
'Must be 0 or greater',
'Max value is 65535'
]}
name="publish_time_mixer" name="publish_time_mixer"
label="Mixer Publish Interval (seconds, 0=on change)" label="Mixer Publish Interval (seconds, 0=on change)"
fullWidth fullWidth
@@ -249,8 +344,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Publish time is required', "Must be a number", "Must be 0 or greater", "Max value is 65535"]} 'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Publish time is required',
'Must be a number',
'Must be 0 or greater',
'Max value is 65535'
]}
name="publish_time_sensor" name="publish_time_sensor"
label="Sensors Publish Interval (seconds, 0=on change)" label="Sensors Publish Interval (seconds, 0=on change)"
fullWidth fullWidth
@@ -261,8 +366,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']} validators={[
errorMessages={['Publish time is required', "Must be a number", "Must be 0 or greater", "Max value is 65535"]} 'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Publish time is required',
'Must be a number',
'Must be 0 or greater',
'Max value is 65535'
]}
name="publish_time_other" name="publish_time_other"
label="All other Modules Publish Interval (seconds, 0=on change)" label="All other Modules Publish Interval (seconds, 0=on change)"
fullWidth fullWidth
@@ -273,7 +388,12 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
margin="normal" margin="normal"
/> />
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

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

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { MQTT_STATUS_ENDPOINT } from '../api'; import { MQTT_STATUS_ENDPOINT } from '../api';
import MqttStatusForm from './MqttStatusForm'; import MqttStatusForm from './MqttStatusForm';
@@ -9,7 +14,6 @@ import { MqttStatus } from './types';
type MqttStatusControllerProps = RestControllerProps<MqttStatus>; type MqttStatusControllerProps = RestControllerProps<MqttStatus>;
class MqttStatusController extends Component<MqttStatusControllerProps> { class MqttStatusController extends Component<MqttStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,10 +23,10 @@ class MqttStatusController extends Component<MqttStatusControllerProps> {
<SectionContent title="MQTT Status"> <SectionContent title="MQTT Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <MqttStatusForm {...formProps} />} render={(formProps) => <MqttStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }

View File

@@ -1,23 +1,39 @@
import React, { Component, Fragment } from 'react'; import { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@material-ui/core';
import DeviceHubIcon from '@material-ui/icons/DeviceHub'; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from '@material-ui/icons/Refresh';
import ReportIcon from '@material-ui/icons/Report'; import ReportIcon from '@material-ui/icons/Report';
import SpeakerNotesOffIcon from "@material-ui/icons/SpeakerNotesOff"; import SpeakerNotesOffIcon from '@material-ui/icons/SpeakerNotesOff';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; import {
import { mqttStatusHighlight, mqttStatus, mqttPublishHighlight, disconnectReason } from './MqttStatus'; RestFormProps,
FormActions,
FormButton,
HighlightAvatar
} from '../components';
import {
mqttStatusHighlight,
mqttStatus,
mqttPublishHighlight,
disconnectReason
} from './MqttStatus';
import { MqttStatus } from './types'; import { MqttStatus } from './types';
type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme; type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme;
class MqttStatusForm extends Component<MqttStatusFormProps> { class MqttStatusForm extends Component<MqttStatusFormProps> {
renderConnectionStatus() { renderConnectionStatus() {
const { data, theme } = this.props const { data, theme } = this.props;
if (data.connected) { if (data.connected) {
return ( return (
<Fragment> <Fragment>
@@ -29,16 +45,16 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<HighlightAvatar color={mqttPublishHighlight(data, theme)}> <HighlightAvatar color={mqttPublishHighlight(data, theme)}>
<SpeakerNotesOffIcon /> <SpeakerNotesOffIcon />
</HighlightAvatar> </HighlightAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="MQTT Publish Errors" primary="MQTT Publish Errors"
secondary={data.mqtt_fails} secondary={data.mqtt_fails}
/> />
</ListItem> </ListItem>
</Fragment> </Fragment>
); );
} }
@@ -50,7 +66,10 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
<ReportIcon /> <ReportIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} /> <ListItemText
primary="Disconnect Reason"
secondary={disconnectReason(data)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</Fragment> </Fragment>
@@ -58,7 +77,7 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
} }
createListItems() { createListItems() {
const { data, theme } = this.props const { data, theme } = this.props;
return ( return (
<Fragment> <Fragment>
<ListItem> <ListItem>
@@ -78,18 +97,20 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
render() { render() {
return ( return (
<Fragment> <Fragment>
<List> <List>{this.createListItems()}</List>
{this.createListItems()}
</List>
<FormActions> <FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>
</Fragment> </Fragment>
); );
} }
} }
export default withTheme(MqttStatusForm); export default withTheme(MqttStatusForm);

View File

@@ -40,5 +40,6 @@ export interface MqttSettings {
mqtt_retain: boolean; mqtt_retain: boolean;
ha_enabled: boolean; ha_enabled: boolean;
ha_climate_format: number; ha_climate_format: number;
nested_format: boolean; nested_format: number;
subscribe_format: number;
} }

View File

@@ -1,22 +1,31 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components'; import { MenuAppBar } from '../components';
import NetworkStatusController from './NetworkStatusController'; import NetworkStatusController from './NetworkStatusController';
import NetworkSettingsController from './NetworkSettingsController'; import NetworkSettingsController from './NetworkSettingsController';
import WiFiNetworkScanner from './WiFiNetworkScanner'; import WiFiNetworkScanner from './WiFiNetworkScanner';
import { NetworkConnectionContext, NetworkConnectionContextValue } from './NetworkConnectionContext'; import {
NetworkConnectionContext,
NetworkConnectionContextValue
} from './NetworkConnectionContext';
import { WiFiNetwork } from './types'; import { WiFiNetwork } from './types';
type NetworkConnectionProps = AuthenticatedContextProps & RouteComponentProps; type NetworkConnectionProps = AuthenticatedContextProps & RouteComponentProps;
class NetworkConnection extends Component<NetworkConnectionProps, NetworkConnectionContextValue> { class NetworkConnection extends Component<
NetworkConnectionProps,
NetworkConnectionContextValue
> {
constructor(props: NetworkConnectionProps) { constructor(props: NetworkConnectionProps) {
super(props); super(props);
this.state = { this.state = {
@@ -28,13 +37,13 @@ class NetworkConnection extends Component<NetworkConnectionProps, NetworkConnect
selectNetwork = (network: WiFiNetwork) => { selectNetwork = (network: WiFiNetwork) => {
this.setState({ selectedNetwork: network }); this.setState({ selectedNetwork: network });
this.props.history.push('/network/settings'); this.props.history.push('/network/settings');
} };
deselectNetwork = () => { deselectNetwork = () => {
this.setState({ selectedNetwork: undefined }); this.setState({ selectedNetwork: undefined });
} };
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { handleTabChange = (path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
@@ -43,20 +52,44 @@ class NetworkConnection extends Component<NetworkConnectionProps, NetworkConnect
return ( return (
<NetworkConnectionContext.Provider value={this.state}> <NetworkConnectionContext.Provider value={this.state}>
<MenuAppBar sectionTitle="Network Connection"> <MenuAppBar sectionTitle="Network Connection">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/network/status" label="Network Status" /> <Tab value="/network/status" label="Network Status" />
<Tab value="/network/scan" label="Scan Networks" disabled={!authenticatedContext.me.admin} /> <Tab
<Tab value="/network/settings" label="Network Settings" disabled={!authenticatedContext.me.admin} /> value="/network/scan"
label="Scan WiFi Networks"
disabled={!authenticatedContext.me.admin}
/>
<Tab
value="/network/settings"
label="Network Settings"
disabled={!authenticatedContext.me.admin}
/>
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/network/status" component={NetworkStatusController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path="/network/scan" component={WiFiNetworkScanner} /> exact
<AuthenticatedRoute exact path="/network/settings" component={NetworkSettingsController} /> path="/network/status"
component={NetworkStatusController}
/>
<AuthenticatedRoute
exact
path="/network/scan"
component={WiFiNetworkScanner}
/>
<AuthenticatedRoute
exact
path="/network/settings"
component={NetworkSettingsController}
/>
<Redirect to="/network/status" /> <Redirect to="/network/status" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
</NetworkConnectionContext.Provider> </NetworkConnectionContext.Provider>
) );
} }
} }

View File

@@ -7,7 +7,7 @@ export interface NetworkConnectionContextValue {
deselectNetwork: () => void; deselectNetwork: () => void;
} }
const NetworkConnectionContextDefaultValue = {} as NetworkConnectionContextValue const NetworkConnectionContextDefaultValue = {} as NetworkConnectionContextValue;
export const NetworkConnectionContext = React.createContext( export const NetworkConnectionContext = React.createContext(
NetworkConnectionContextDefaultValue NetworkConnectionContextDefaultValue
); );

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import NetworkSettingsForm from './NetworkSettingsForm'; import NetworkSettingsForm from './NetworkSettingsForm';
import { NETWORK_SETTINGS_ENDPOINT } from '../api'; import { NETWORK_SETTINGS_ENDPOINT } from '../api';
import { NetworkSettings } from './types'; import { NetworkSettings } from './types';
@@ -8,7 +13,6 @@ import { NetworkSettings } from './types';
type NetworkSettingsControllerProps = RestControllerProps<NetworkSettings>; type NetworkSettingsControllerProps = RestControllerProps<NetworkSettings>;
class NetworkSettingsController extends Component<NetworkSettingsControllerProps> { class NetworkSettingsController extends Component<NetworkSettingsControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -18,12 +22,14 @@ class NetworkSettingsController extends Component<NetworkSettingsControllerProps
<SectionContent title="Network Settings"> <SectionContent title="Network Settings">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <NetworkSettingsForm {...formProps} />} render={(formProps) => <NetworkSettingsForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(NETWORK_SETTINGS_ENDPOINT, NetworkSettingsController); export default restController(
NETWORK_SETTINGS_ENDPOINT,
NetworkSettingsController
);

View File

@@ -1,7 +1,14 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { TextValidator, SelectValidator, ValidatorForm } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { Checkbox, List, ListItem, ListItemText, ListItemAvatar, ListItemSecondaryAction } from '@material-ui/core'; import {
Checkbox,
List,
ListItem,
ListItemText,
ListItemAvatar,
ListItemSecondaryAction
} from '@material-ui/core';
import Avatar from '@material-ui/core/Avatar'; import Avatar from '@material-ui/core/Avatar';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@material-ui/core/IconButton';
@@ -9,34 +16,43 @@ import LockIcon from '@material-ui/icons/Lock';
import LockOpenIcon from '@material-ui/icons/LockOpen'; import LockOpenIcon from '@material-ui/icons/LockOpen';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import MenuItem from '@material-ui/core/MenuItem';
import { RestFormProps, PasswordValidator, BlockFormControlLabel, FormActions, FormButton } from '../components'; import {
RestFormProps,
PasswordValidator,
BlockFormControlLabel,
FormActions,
FormButton
} from '../components';
import { isIP, isHostname, optional } from '../validators'; import { isIP, isHostname, optional } from '../validators';
import { NetworkConnectionContext, NetworkConnectionContextValue } from './NetworkConnectionContext'; import {
NetworkConnectionContext,
NetworkConnectionContextValue
} from './NetworkConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes'; import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
import { NetworkSettings } from './types'; import { NetworkSettings } from './types';
type NetworkStatusFormProps = RestFormProps<NetworkSettings>; type NetworkStatusFormProps = RestFormProps<NetworkSettings>;
class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> { class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
static contextType = NetworkConnectionContext; static contextType = NetworkConnectionContext;
context!: React.ContextType<typeof NetworkConnectionContext>; context!: React.ContextType<typeof NetworkConnectionContext>;
constructor(props: NetworkStatusFormProps, context: NetworkConnectionContextValue) { constructor(
props: NetworkStatusFormProps,
context: NetworkConnectionContextValue
) {
super(props); super(props);
const { selectedNetwork } = context; const { selectedNetwork } = context;
if (selectedNetwork) { if (selectedNetwork) {
const networkSettings: NetworkSettings = { const networkSettings: NetworkSettings = {
ssid: selectedNetwork.ssid, ssid: selectedNetwork.ssid,
password: "", password: '',
hostname: props.data.hostname, hostname: props.data.hostname,
ethernet_profile: 0, static_ip_config: false
static_ip_config: false, };
}
props.setData(networkSettings); props.setData(networkSettings);
} }
} }
@@ -50,7 +66,7 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
deselectNetworkAndLoadData = () => { deselectNetworkAndLoadData = () => {
this.context.deselectNetwork(); this.context.deselectNetwork();
this.props.loadData(); this.props.loadData();
} };
componentWillUnmount() { componentWillUnmount() {
this.context.deselectNetwork(); this.context.deselectNetwork();
@@ -61,41 +77,51 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
const { data, handleValueChange, saveData } = this.props; const { data, handleValueChange, saveData } = this.props;
return ( return (
<ValidatorForm onSubmit={saveData} ref="NetworkSettingsForm"> <ValidatorForm onSubmit={saveData} ref="NetworkSettingsForm">
{ {selectedNetwork ? (
selectedNetwork ? <List>
<List> <ListItem>
<ListItem> <ListItemAvatar>
<ListItemAvatar> <Avatar>
<Avatar> {isNetworkOpen(selectedNetwork) ? (
{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />} <LockOpenIcon />
</Avatar> ) : (
</ListItemAvatar> <LockIcon />
<ListItemText )}
primary={selectedNetwork.ssid} </Avatar>
secondary={"Security: " + networkSecurityMode(selectedNetwork) + ", Ch: " + selectedNetwork.channel} </ListItemAvatar>
/> <ListItemText
<ListItemSecondaryAction> primary={selectedNetwork.ssid}
<IconButton aria-label="Manual Config" onClick={deselectNetwork}> secondary={
<DeleteIcon /> 'Security: ' +
</IconButton> networkSecurityMode(selectedNetwork) +
</ListItemSecondaryAction> ', Ch: ' +
</ListItem> selectedNetwork.channel
</List> }
: />
<TextValidator <ListItemSecondaryAction>
validators={['matchRegexp:^.{0,32}$']} <IconButton
errorMessages={['SSID must be 32 characters or less']} aria-label="Manual Config"
name="ssid" onClick={deselectNetwork}
label="SSID" >
fullWidth <DeleteIcon />
variant="outlined" </IconButton>
value={data.ssid} </ListItemSecondaryAction>
onChange={handleValueChange('ssid')} </ListItem>
margin="normal" </List>
/> ) : (
} <TextValidator
{ validators={['matchRegexp:^.{0,32}$']}
(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && errorMessages={['SSID must be 32 characters or less']}
name="ssid"
label="SSID (leave blank to disable WiFi)"
fullWidth
variant="outlined"
value={data.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
)}
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
<PasswordValidator <PasswordValidator
validators={['matchRegexp:^.{0,64}$']} validators={['matchRegexp:^.{0,64}$']}
errorMessages={['Password must be 64 characters or less']} errorMessages={['Password must be 64 characters or less']}
@@ -107,10 +133,10 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
onChange={handleValueChange('password')} onChange={handleValueChange('password')}
margin="normal" margin="normal"
/> />
} )}
<TextValidator <TextValidator
validators={['required', 'isHostname']} validators={['required', 'isHostname']}
errorMessages={['Hostname is required', "Not a valid hostname"]} errorMessages={['Hostname is required', 'Not a valid hostname']}
name="hostname" name="hostname"
label="Hostname" label="Hostname"
fullWidth fullWidth
@@ -119,29 +145,17 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
onChange={handleValueChange('hostname')} onChange={handleValueChange('hostname')}
margin="normal" margin="normal"
/> />
<SelectValidator name="ems_bus_id"
label="Ethernet Profile"
value={data.ethernet_profile}
fullWidth
variant="outlined"
onChange={handleValueChange('ethernet_profile')}
margin="normal">
<MenuItem value={0}>None (wifi only)</MenuItem>
<MenuItem value={1}>Profile 1 (LAN8720)</MenuItem>
<MenuItem value={2}>Profile 2 (TLK110)</MenuItem>
</SelectValidator>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
<Checkbox <Checkbox
value="static_ip_config" value="static_ip_config"
checked={data.static_ip_config} checked={data.static_ip_config}
onChange={handleValueChange("static_ip_config")} onChange={handleValueChange('static_ip_config')}
/> />
} }
label="Static IP Config" label="Static IP Config"
/> />
{ {data.static_ip_config && (
data.static_ip_config &&
<Fragment> <Fragment>
<TextValidator <TextValidator
validators={['required', 'isIP']} validators={['required', 'isIP']}
@@ -167,7 +181,10 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
/> />
<TextValidator <TextValidator
validators={['required', 'isIP']} validators={['required', 'isIP']}
errorMessages={['Subnet mask is required', 'Must be an IP address']} errorMessages={[
'Subnet mask is required',
'Must be an IP address'
]}
name="subnet_mask" name="subnet_mask"
label="Subnet" label="Subnet"
fullWidth fullWidth
@@ -199,9 +216,14 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
margin="normal" margin="normal"
/> />
</Fragment> </Fragment>
} )}
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>
@@ -210,4 +232,4 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
} }
} }
export default NetworkSettingsForm; export default NetworkSettingsForm;

View File

@@ -2,12 +2,21 @@ import { Theme } from '@material-ui/core';
import { NetworkStatus, NetworkConnectionStatus } from './types'; import { NetworkStatus, NetworkConnectionStatus } from './types';
export const isConnected = ({ status }: NetworkStatus) => { export const isConnected = ({ status }: NetworkStatus) => {
return ((status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED) || (status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED)); return (
} status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED
);
};
export const isWiFi = ({ status }: NetworkStatus) => (status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED) export const isWiFi = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const networkStatusHighlight = ({ status }: NetworkStatus, theme: Theme) => { export const networkStatusHighlight = (
{ status }: NetworkStatus,
theme: Theme
) => {
switch (status) { switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -22,27 +31,27 @@ export const networkStatusHighlight = ({ status }: NetworkStatus, theme: Theme)
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
} };
export const networkStatus = ({ status }: NetworkStatus) => { export const networkStatus = ({ status }: NetworkStatus) => {
switch (status) { switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return "Inactive"; return 'Inactive';
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return "Idle"; return 'Idle';
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL: case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return "No SSID Available"; return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED: case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return "Connected (WiFi)"; return 'Connected (WiFi)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED: case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return "Connected (Ethernet)"; return 'Connected (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED: case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return "Connection Failed"; return 'Connection Failed';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST: case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return "Connection Lost"; return 'Connection Lost';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return "Disconnected"; return 'Disconnected';
default: default:
return "Unknown"; return 'Unknown';
} }
} };

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import NetworkStatusForm from './NetworkStatusForm'; import NetworkStatusForm from './NetworkStatusForm';
import { NETWORK_STATUS_ENDPOINT } from '../api'; import { NETWORK_STATUS_ENDPOINT } from '../api';
import { NetworkStatus } from './types'; import { NetworkStatus } from './types';
@@ -8,7 +13,6 @@ import { NetworkStatus } from './types';
type NetworkStatusControllerProps = RestControllerProps<NetworkStatus>; type NetworkStatusControllerProps = RestControllerProps<NetworkStatus>;
class NetworkStatusController extends Component<NetworkStatusControllerProps> { class NetworkStatusController extends Component<NetworkStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -18,12 +22,11 @@ class NetworkStatusController extends Component<NetworkStatusControllerProps> {
<SectionContent title="Network Status"> <SectionContent title="Network Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <NetworkStatusForm {...formProps} />} render={(formProps) => <NetworkStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(NETWORK_STATUS_ENDPOINT, NetworkStatusController); export default restController(NETWORK_STATUS_ENDPOINT, NetworkStatusController);

View File

@@ -1,45 +1,64 @@
import React, { Component, Fragment } from 'react'; import { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@material-ui/core';
import DNSIcon from '@material-ui/icons/Dns'; import DNSIcon from '@material-ui/icons/Dns';
import WifiIcon from '@material-ui/icons/Wifi'; import WifiIcon from '@material-ui/icons/Wifi';
import RouterIcon from '@material-ui/icons/Router';
import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent'; import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub'; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; import {
import { networkStatus, networkStatusHighlight, isConnected, isWiFi } from './NetworkStatus'; RestFormProps,
FormActions,
FormButton,
HighlightAvatar
} from '../components';
import {
networkStatus,
networkStatusHighlight,
isConnected,
isWiFi,
isEthernet
} from './NetworkStatus';
import { NetworkStatus } from './types'; import { NetworkStatus } from './types';
type NetworkStatusFormProps = RestFormProps<NetworkStatus> & WithTheme; type NetworkStatusFormProps = RestFormProps<NetworkStatus> & WithTheme;
class NetworkStatusForm extends Component<NetworkStatusFormProps> { class NetworkStatusForm extends Component<NetworkStatusFormProps> {
dnsServers(status: NetworkStatus) { dnsServers(status: NetworkStatus) {
if (!status.dns_ip_1) { if (!status.dns_ip_1) {
return "none"; return 'none';
} }
return status.dns_ip_1 + (status.dns_ip_2 ? ',' + status.dns_ip_2 : ''); return status.dns_ip_1 + (status.dns_ip_2 ? ',' + status.dns_ip_2 : '');
} }
createListItems() { createListItems() {
const { data, theme } = this.props const { data, theme } = this.props;
return ( return (
<Fragment> <Fragment>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<HighlightAvatar color={networkStatusHighlight(data, theme)}> <HighlightAvatar color={networkStatusHighlight(data, theme)}>
<WifiIcon /> {isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</HighlightAvatar> </HighlightAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} /> <ListItemText primary="Status" secondary={networkStatus(data)} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
{ {isWiFi(data) && (
isWiFi(data) &&
<Fragment> <Fragment>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -51,8 +70,8 @@ class NetworkStatusForm extends Component<NetworkStatusFormProps> {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</Fragment> </Fragment>
} )}
{ isConnected(data) && {isConnected(data) && (
<Fragment> <Fragment>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -67,14 +86,20 @@ class NetworkStatusForm extends Component<NetworkStatusFormProps> {
<DeviceHubIcon /> <DeviceHubIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} /> <ListItemText
primary="MAC Address"
secondary={data.mac_address}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar>#</Avatar> <Avatar>#</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Subnet Mask" secondary={data.subnet_mask} /> <ListItemText
primary="Subnet Mask"
secondary={data.subnet_mask}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -83,7 +108,10 @@ class NetworkStatusForm extends Component<NetworkStatusFormProps> {
<SettingsInputComponentIcon /> <SettingsInputComponentIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Gateway IP" secondary={data.gateway_ip || "none"} /> <ListItemText
primary="Gateway IP"
secondary={data.gateway_ip || 'none'}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -92,11 +120,14 @@ class NetworkStatusForm extends Component<NetworkStatusFormProps> {
<DNSIcon /> <DNSIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="DNS Server IP" secondary={this.dnsServers(data)} /> <ListItemText
primary="DNS Server IP"
secondary={this.dnsServers(data)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</Fragment> </Fragment>
} )}
</Fragment> </Fragment>
); );
} }
@@ -104,18 +135,20 @@ class NetworkStatusForm extends Component<NetworkStatusFormProps> {
render() { render() {
return ( return (
<Fragment> <Fragment>
<List> <List>{this.createListItems()}</List>
{this.createListItems()}
</List>
<FormActions> <FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>
</Fragment> </Fragment>
); );
} }
} }
export default withTheme(NetworkStatusForm); export default withTheme(NetworkStatusForm);

View File

@@ -1,7 +1,14 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
import { createStyles, WithStyles, Theme, withStyles, Typography, LinearProgress } from '@material-ui/core'; import {
createStyles,
WithStyles,
Theme,
withStyles,
Typography,
LinearProgress
} from '@material-ui/core';
import PermScanWifiIcon from '@material-ui/icons/PermScanWifi'; import PermScanWifiIcon from '@material-ui/icons/PermScanWifi';
import { FormActions, FormButton, SectionContent } from '../components'; import { FormActions, FormButton, SectionContent } from '../components';
@@ -11,9 +18,9 @@ import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../api';
import WiFiNetworkSelector from './WiFiNetworkSelector'; import WiFiNetworkSelector from './WiFiNetworkSelector';
import { WiFiNetworkList, WiFiNetwork } from './types'; import { WiFiNetworkList, WiFiNetwork } from './types';
const NUM_POLLS = 10 const NUM_POLLS = 10;
const POLLING_FREQUENCY = 500 const POLLING_FREQUENCY = 500;
const RETRY_EXCEPTION_TYPE = "retry" const RETRY_EXCEPTION_TYPE = 'retry';
interface WiFiNetworkScannerState { interface WiFiNetworkScannerState {
scanningForNetworks: boolean; scanningForNetworks: boolean;
@@ -21,28 +28,31 @@ interface WiFiNetworkScannerState {
networkList?: WiFiNetworkList; networkList?: WiFiNetworkList;
} }
const styles = (theme: Theme) => createStyles({ const styles = (theme: Theme) =>
scanningSettings: { createStyles({
margin: theme.spacing(0.5), scanningSettings: {
}, margin: theme.spacing(0.5)
scanningSettingsDetails: { },
margin: theme.spacing(4), scanningSettingsDetails: {
textAlign: "center" margin: theme.spacing(4),
}, textAlign: 'center'
scanningProgress: { },
margin: theme.spacing(4), scanningProgress: {
textAlign: "center" margin: theme.spacing(4),
} textAlign: 'center'
}); }
});
type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles<typeof styles>; type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles<typeof styles>;
class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkScannerState> { class WiFiNetworkScanner extends Component<
WiFiNetworkScannerProps,
pollCount: number = 0; WiFiNetworkScannerState
> {
pollCount = 0;
state: WiFiNetworkScannerState = { state: WiFiNetworkScannerState = {
scanningForNetworks: false, scanningForNetworks: false
}; };
componentDidMount() { componentDidMount() {
@@ -54,23 +64,36 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
if (!scanningForNetworks) { if (!scanningForNetworks) {
this.scanNetworks(); this.scanNetworks();
} }
} };
scanNetworks() { scanNetworks() {
this.pollCount = 0; this.pollCount = 0;
this.setState({ scanningForNetworks: true, networkList: undefined, errorMessage: undefined }); this.setState({
redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT).then(response => { scanningForNetworks: true,
if (response.status === 202) { networkList: undefined,
this.schedulePollTimeout(); errorMessage: undefined
return;
}
throw Error("Scanning for networks returned unexpected response code: " + response.status);
}).catch(error => {
this.props.enqueueSnackbar("Problem scanning: " + error.message, {
variant: 'error',
});
this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
}); });
redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT)
.then((response) => {
if (response.status === 202) {
this.schedulePollTimeout();
return;
}
throw Error(
'Scanning for networks returned unexpected response code: ' +
response.status
);
})
.catch((error) => {
this.props.enqueueSnackbar('Problem scanning: ' + error.message, {
variant: 'error'
});
this.setState({
scanningForNetworks: false,
networkList: undefined,
errorMessage: error.message
});
});
} }
schedulePollTimeout() { schedulePollTimeout() {
@@ -80,21 +103,20 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
retryError() { retryError() {
return { return {
name: RETRY_EXCEPTION_TYPE, name: RETRY_EXCEPTION_TYPE,
message: "Network list not ready, will retry in " + POLLING_FREQUENCY + "ms." message:
'Network list not ready, will retry in ' + POLLING_FREQUENCY + 'ms.'
}; };
} }
compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) { compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) {
if (network1.rssi < network2.rssi) if (network1.rssi < network2.rssi) return 1;
return 1; if (network1.rssi > network2.rssi) return -1;
if (network1.rssi > network2.rssi)
return -1;
return 0; return 0;
} }
pollNetworkList = () => { pollNetworkList = () => {
redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT) redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT)
.then(response => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} }
@@ -103,24 +125,34 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
this.schedulePollTimeout(); this.schedulePollTimeout();
throw this.retryError(); throw this.retryError();
} else { } else {
throw Error("Device did not return network list in timely manner."); throw Error('Device did not return network list in timely manner.');
} }
} }
throw Error("Device returned unexpected response code: " + response.status); throw Error(
'Device returned unexpected response code: ' + response.status
);
}) })
.then(json => { .then((json) => {
json.networks.sort(this.compareNetworks) json.networks.sort(this.compareNetworks);
this.setState({ scanningForNetworks: false, networkList: json, errorMessage: undefined }) this.setState({
scanningForNetworks: false,
networkList: json,
errorMessage: undefined
});
}) })
.catch(error => { .catch((error) => {
if (error.name !== RETRY_EXCEPTION_TYPE) { if (error.name !== RETRY_EXCEPTION_TYPE) {
this.props.enqueueSnackbar("Problem scanning: " + error.message, { this.props.enqueueSnackbar('Problem scanning: ' + error.message, {
variant: 'error', variant: 'error'
});
this.setState({
scanningForNetworks: false,
networkList: undefined,
errorMessage: error.message
}); });
this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
} }
}); });
} };
renderNetworkScanner() { renderNetworkScanner() {
const { classes } = this.props; const { classes } = this.props;
@@ -144,9 +176,7 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
</div> </div>
); );
} }
return ( return <WiFiNetworkSelector networkList={networkList} />;
<WiFiNetworkSelector networkList={networkList} />
);
} }
render() { render() {
@@ -155,14 +185,19 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
<SectionContent title="Network Scanner"> <SectionContent title="Network Scanner">
{this.renderNetworkScanner()} {this.renderNetworkScanner()}
<FormActions> <FormActions>
<FormButton startIcon={<PermScanWifiIcon />} variant="contained" color="secondary" onClick={this.requestNetworkScan} disabled={scanningForNetworks}> <FormButton
startIcon={<PermScanWifiIcon />}
variant="contained"
color="secondary"
onClick={this.requestNetworkScan}
disabled={scanningForNetworks}
>
Scan again&hellip; Scan again&hellip;
</FormButton> </FormButton>
</FormActions> </FormActions>
</SectionContent> </SectionContent>
); );
} }
} }
export default withSnackbar(withStyles(styles)(WiFiNetworkScanner)); export default withSnackbar(withStyles(styles)(WiFiNetworkScanner));

View File

@@ -1,7 +1,13 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Avatar, Badge } from '@material-ui/core'; import { Avatar, Badge } from '@material-ui/core';
import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core'; import {
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemAvatar
} from '@material-ui/core';
import WifiIcon from '@material-ui/icons/Wifi'; import WifiIcon from '@material-ui/icons/Wifi';
import LockIcon from '@material-ui/icons/Lock'; import LockIcon from '@material-ui/icons/Lock';
@@ -16,13 +22,16 @@ interface WiFiNetworkSelectorProps {
} }
class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> { class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> {
static contextType = NetworkConnectionContext; static contextType = NetworkConnectionContext;
context!: React.ContextType<typeof NetworkConnectionContext>; context!: React.ContextType<typeof NetworkConnectionContext>;
renderNetwork = (network: WiFiNetwork) => { renderNetwork = (network: WiFiNetwork) => {
return ( return (
<ListItem key={network.bssid} button onClick={() => this.context.selectNetwork(network)}> <ListItem
key={network.bssid}
button
onClick={() => this.context.selectNetwork(network)}
>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />} {isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}
@@ -30,25 +39,27 @@ class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> {
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={network.ssid} primary={network.ssid}
secondary={"Security: " + networkSecurityMode(network) + ", Ch: " + network.channel} secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel
}
/> />
<ListItemIcon> <ListItemIcon>
<Badge badgeContent={network.rssi + "db"}> <Badge badgeContent={network.rssi + 'db'}>
<WifiIcon /> <WifiIcon />
</Badge> </Badge>
</ListItemIcon> </ListItemIcon>
</ListItem> </ListItem>
); );
} };
render() { render() {
return ( return (
<List> <List>{this.props.networkList.networks.map(this.renderNetwork)}</List>
{this.props.networkList.networks.map(this.renderNetwork)}
</List>
); );
} }
} }
export default WiFiNetworkSelector; export default WiFiNetworkSelector;

View File

@@ -1,22 +1,23 @@
import { WiFiNetwork, WiFiEncryptionType } from "./types"; import { WiFiNetwork, WiFiEncryptionType } from './types';
export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) => encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN; export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => { export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
switch (encryption_type) { switch (encryption_type) {
case WiFiEncryptionType.WIFI_AUTH_WEP: case WiFiEncryptionType.WIFI_AUTH_WEP:
return "WEP"; return 'WEP';
case WiFiEncryptionType.WIFI_AUTH_WPA_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA_PSK:
return "WPA"; return 'WPA';
case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK:
return "WPA2"; return 'WPA2';
case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
return "WPA/WPA2"; return 'WPA/WPA2';
case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE: case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
return "WPA2 Enterprise"; return 'WPA2 Enterprise';
case WiFiEncryptionType.WIFI_AUTH_OPEN: case WiFiEncryptionType.WIFI_AUTH_OPEN:
return "None"; return 'None';
default: default:
return "Unknown"; return 'Unknown';
} }
} };

View File

@@ -36,7 +36,6 @@ export interface NetworkSettings {
ssid: string; ssid: string;
password: string; password: string;
hostname: string; hostname: string;
ethernet_profile: number;
static_ip_config: boolean; static_ip_config: boolean;
local_ip?: string; local_ip?: string;
gateway_ip?: string; gateway_ip?: string;

View File

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

View File

@@ -1,10 +1,19 @@
import React from 'react'; import React from 'react';
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator'; import {
TextValidator,
ValidatorForm,
SelectValidator
} from 'react-material-ui-form-validator';
import { Checkbox, MenuItem } from '@material-ui/core'; import { Checkbox, MenuItem } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel } from '../components'; import {
RestFormProps,
FormActions,
FormButton,
BlockFormControlLabel
} from '../components';
import { isIP, isHostname, or } from '../validators'; import { isIP, isHostname, or } from '../validators';
import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ'; import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ';
@@ -13,7 +22,6 @@ import { NTPSettings } from './types';
type NTPSettingsFormProps = RestFormProps<NTPSettings>; type NTPSettingsFormProps = RestFormProps<NTPSettings>;
class NTPSettingsForm extends React.Component<NTPSettingsFormProps> { class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
} }
@@ -25,7 +33,7 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
tz_label: event.target.value, tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value] tz_format: TIME_ZONES[event.target.value]
}); });
} };
render() { render() {
const { data, handleValueChange, saveData } = this.props; const { data, handleValueChange, saveData } = this.props;
@@ -43,7 +51,10 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
/> />
<TextValidator <TextValidator
validators={['required', 'isIPOrHostname']} validators={['required', 'isIPOrHostname']}
errorMessages={['Server is required', "Not a valid IP address or hostname"]} errorMessages={[
'Server is required',
'Not a valid IP address or hostname'
]}
name="server" name="server"
label="Server" label="Server"
fullWidth fullWidth
@@ -68,7 +79,12 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
{timeZoneSelectItems()} {timeZoneSelectItems()}
</SelectValidator> </SelectValidator>
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

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

View File

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

View File

@@ -1,8 +1,23 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText, Button } from '@material-ui/core'; import {
import { Dialog, DialogTitle, DialogContent, DialogActions, Box, TextField } from '@material-ui/core'; 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 SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle';
import AccessTimeIcon from '@material-ui/icons/AccessTime'; import AccessTimeIcon from '@material-ui/icons/AccessTime';
@@ -13,12 +28,22 @@ import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormButton, HighlightAvatar } from '../components'; import { RestFormProps, FormButton, HighlightAvatar } from '../components';
import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus'; import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus';
import { formatDuration, formatDateTime, formatLocalDateTime } from './TimeFormat'; import {
formatDuration,
formatDateTime,
formatLocalDateTime
} from './TimeFormat';
import { NTPStatus, Time } from './types'; import { NTPStatus, Time } from './types';
import { redirectingAuthorizedFetch, withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
redirectingAuthorizedFetch,
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import { TIME_ENDPOINT } from '../api'; import { TIME_ENDPOINT } from '../api';
type NTPStatusFormProps = RestFormProps<NTPStatus> & WithTheme & AuthenticatedContextProps; type NTPStatusFormProps = RestFormProps<NTPStatus> &
WithTheme &
AuthenticatedContextProps;
interface NTPStatusFormState { interface NTPStatusFormState {
settingTime: boolean; settingTime: boolean;
@@ -27,7 +52,6 @@ interface NTPStatusFormState {
} }
class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> { class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
constructor(props: NTPStatusFormProps) { constructor(props: NTPStatusFormProps) {
super(props); super(props);
this.state = { this.state = {
@@ -41,20 +65,20 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
this.setState({ this.setState({
localTime: event.target.value localTime: event.target.value
}); });
} };
openSetTime = () => { openSetTime = () => {
this.setState({ this.setState({
localTime: formatLocalDateTime(new Date()), localTime: formatLocalDateTime(new Date()),
settingTime: true settingTime: true
}); });
} };
closeSetTime = () => { closeSetTime = () => {
this.setState({ this.setState({
settingTime: false settingTime: false
}); });
} };
createTime = (): Time => ({ createTime = (): Time => ({
local_time: formatLocalDateTime(new Date(this.state.localTime)) local_time: formatLocalDateTime(new Date(this.state.localTime))
@@ -62,37 +86,48 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
configureTime = () => { configureTime = () => {
this.setState({ processing: true }); this.setState({ processing: true });
redirectingAuthorizedFetch(TIME_ENDPOINT, redirectingAuthorizedFetch(TIME_ENDPOINT, {
{ method: 'POST',
method: 'POST', body: JSON.stringify(this.createTime()),
body: JSON.stringify(this.createTime()), headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' }
} })
}) .then((response) => {
.then(response => {
if (response.status === 200) { if (response.status === 200) {
this.props.enqueueSnackbar("Time set successfully", { variant: 'success' }); this.props.enqueueSnackbar('Time set successfully', {
this.setState({ processing: false, settingTime: false }, this.props.loadData); variant: 'success'
});
this.setState(
{ processing: false, settingTime: false },
this.props.loadData
);
} else { } else {
throw Error("Error setting time, status code: " + response.status); throw Error('Error setting time, status code: ' + response.status);
} }
}) })
.catch(error => { .catch((error) => {
this.props.enqueueSnackbar(error.message || "Problem setting the time", { variant: 'error' }); this.props.enqueueSnackbar(
error.message || 'Problem setting the time',
{ variant: 'error' }
);
this.setState({ processing: false, settingTime: false }); this.setState({ processing: false, settingTime: false });
}); });
} };
renderSetTimeDialog() { renderSetTimeDialog() {
return ( return (
<Dialog <Dialog
open={this.state.settingTime} open={this.state.settingTime}
onClose={this.closeSetTime} onClose={this.closeSetTime}
fullWidth
maxWidth="sm"
> >
<DialogTitle>Set Time</DialogTitle> <DialogTitle>Set Time</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box mb={2}>Enter local date and time below to set the device's time.</Box> <Box mb={2}>
Enter local date and time below to set the device's time.
</Box>
<TextField <TextField
label="Local Time" label="Local Time"
type="datetime-local" type="datetime-local"
@@ -102,24 +137,35 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
variant="outlined" variant="outlined"
fullWidth fullWidth
InputLabelProps={{ InputLabelProps={{
shrink: true, shrink: true
}} }}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button variant="contained" onClick={this.closeSetTime} color="secondary"> <Button
variant="contained"
onClick={this.closeSetTime}
color="secondary"
>
Cancel Cancel
</Button> </Button>
<Button startIcon={<AccessTimeIcon />} variant="contained" onClick={this.configureTime} disabled={this.state.processing} color="primary" autoFocus> <Button
startIcon={<AccessTimeIcon />}
variant="contained"
onClick={this.configureTime}
disabled={this.state.processing}
color="primary"
autoFocus
>
Set Time Set Time
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) );
} }
render() { render() {
const { data, theme } = this.props const { data, theme } = this.props;
const me = this.props.authenticatedContext.me; const me = this.props.authenticatedContext.me;
return ( return (
<Fragment> <Fragment>
@@ -152,7 +198,10 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
<AccessTimeIcon /> <AccessTimeIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Local Time" secondary={formatDateTime(data.local_time)} /> <ListItemText
primary="Local Time"
secondary={formatDateTime(data.local_time)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -161,7 +210,10 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
<SwapVerticalCircleIcon /> <SwapVerticalCircleIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="UTC Time" secondary={formatDateTime(data.utc_time)} /> <ListItemText
primary="UTC Time"
secondary={formatDateTime(data.utc_time)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -170,19 +222,32 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
<AvTimerIcon /> <AvTimerIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Uptime" secondary={formatDuration(data.uptime)} /> <ListItemText
primary="Uptime"
secondary={formatDuration(data.uptime)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1} padding={1}> <Box flexGrow={1} padding={1}>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</Box> </Box>
{me.admin && !isNtpActive(data) && ( {me.admin && !isNtpActive(data) && (
<Box flexWrap="none" padding={1} whiteSpace="nowrap"> <Box flexWrap="none" padding={1} whiteSpace="nowrap">
<Button onClick={this.openSetTime} variant="contained" color="primary" startIcon={<AccessTimeIcon />}> <Button
onClick={this.openSetTime}
variant="contained"
color="primary"
startIcon={<AccessTimeIcon />}
>
Set Time Set Time
</Button> </Button>
</Box> </Box>

View File

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

View File

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

View File

@@ -1,32 +1,29 @@
import parseMilliseconds from 'parse-ms'; import parseMilliseconds from 'parse-ms';
const LOCALE_FORMAT = new Intl.DateTimeFormat( const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], {
[...window.navigator.languages], day: 'numeric',
{ month: 'short',
day: 'numeric', year: 'numeric',
month: 'short', hour: 'numeric',
year: 'numeric', minute: 'numeric',
hour: 'numeric', second: 'numeric',
minute: 'numeric', hour12: false
second: 'numeric', });
hour12: false
}
);
export const formatDateTime = (dateTime: string) => { export const formatDateTime = (dateTime: string) => {
return LOCALE_FORMAT.format(new Date(dateTime.substr(0, 19))); return LOCALE_FORMAT.format(new Date(dateTime.substr(0, 19)));
} };
export const formatLocalDateTime = (date: Date) => { export const formatLocalDateTime = (date: Date) => {
return new Date(date.getTime() - date.getTimezoneOffset() * 60000) return new Date(date.getTime() - date.getTimezoneOffset() * 60000)
.toISOString() .toISOString()
.slice(0, -1) .slice(0, -1)
.substr(0, 19); .substr(0, 19);
} };
export const formatDuration = (duration: number) => { export const formatDuration = (duration: number) => {
const { days, hours, minutes, seconds } = parseMilliseconds(duration * 1000); const { days, hours, minutes, seconds } = parseMilliseconds(duration * 1000);
var formatted = ''; let formatted = '';
if (days) { if (days) {
formatted += pluralize(days, 'day'); formatted += pluralize(days, 'day');
} }
@@ -40,6 +37,7 @@ export const formatDuration = (duration: number) => {
formatted += pluralize(seconds, 'second'); formatted += pluralize(seconds, 'second');
} }
return formatted; return formatted;
} };
const pluralize = (count: number, noun: string, suffix: string = 's') => ` ${count} ${noun}${count !== 1 ? suffix : ''} `; const pluralize = (count: number, noun: string, suffix = 's') =>
` ${count} ${noun}${count !== 1 ? suffix : ''} `;

View File

@@ -20,4 +20,4 @@ export interface NTPSettings {
export interface Time { export interface Time {
local_time: string; local_time: string;
} }

View File

@@ -0,0 +1,24 @@
import MenuItem from '@material-ui/core/MenuItem';
type BoardProfiles = {
[name: string]: string;
};
export const BOARD_PROFILES: BoardProfiles = {
S32: 'BBQKees Gateway S32',
E32: 'BBQKees Gateway E32',
NODEMCU: 'NodeMCU 32S',
'MH-ET': 'MH-ET Live D1 Mini',
LOLIN: 'Lolin D32',
OLIMEX: 'Olimex ESP32-EVB',
TLK110: 'Generic Ethernet (TLK110)',
LAN8720: 'Generic Ethernet (LAN8720)'
};
export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}>
{BOARD_PROFILES[code]}
</MenuItem>
));
}

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
@@ -12,30 +12,46 @@ import EMSESPDevicesController from './EMSESPDevicesController';
import EMSESPHelp from './EMSESPHelp'; import EMSESPHelp from './EMSESPHelp';
class EMSESP extends Component<RouteComponentProps> { class EMSESP extends Component<RouteComponentProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
render() { render() {
return ( return (
<MenuAppBar sectionTitle="Dashboard"> <MenuAppBar sectionTitle="Dashboard">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
<Tab value={`/${PROJECT_PATH}/devices`} label="Devices & Sensors" /> value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab
value={`/${PROJECT_PATH}/devices`}
label="Devices &amp; Sensors"
/>
<Tab value={`/${PROJECT_PATH}/status`} label="EMS Status" /> <Tab value={`/${PROJECT_PATH}/status`} label="EMS Status" />
<Tab value={`/${PROJECT_PATH}/help`} label="EMS-ESP Help" /> <Tab value={`/${PROJECT_PATH}/help`} label="EMS-ESP Help" />
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/devices`} component={EMSESPDevicesController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/status`} component={EMSESPStatusController} /> exact
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/help`} component={EMSESPHelp} /> path={`/${PROJECT_PATH}/devices`}
component={EMSESPDevicesController}
/>
<AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/status`}
component={EMSESPStatusController}
/>
<AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/help`}
component={EMSESPHelp}
/>
<Redirect to={`/${PROJECT_PATH}/devices`} /> <Redirect to={`/${PROJECT_PATH}/devices`} />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
);
)
} }
} }
export default EMSESP; export default EMSESP;

View File

@@ -1,30 +1,35 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { ENDPOINT_ROOT } from '../api'; import { ENDPOINT_ROOT } from '../api';
import EMSESPDevicesForm from './EMSESPDevicesForm'; import EMSESPDevicesForm from './EMSESPDevicesForm';
import { EMSESPDevices } from './EMSESPtypes'; import { EMSESPDevices } from './EMSESPtypes';
export const EMSESP_DEVICES_ENDPOINT = ENDPOINT_ROOT + "allDevices"; export const EMSESP_DEVICES_ENDPOINT = ENDPOINT_ROOT + 'allDevices';
type EMSESPDevicesControllerProps = RestControllerProps<EMSESPDevices>; type EMSESPDevicesControllerProps = RestControllerProps<EMSESPDevices>;
class EMSESPDevicesController extends Component<EMSESPDevicesControllerProps> { class EMSESPDevicesController extends Component<EMSESPDevicesControllerProps> {
componentDidMount() {
this.props.loadData();
}
componentDidMount() { render() {
this.props.loadData(); return (
} <SectionContent title="Devices &amp; Sensors">
<RestFormLoader
render() { {...this.props}
return ( render={(formProps) => <EMSESPDevicesForm {...formProps} />}
<SectionContent title="Devices & Sensors"> />
<RestFormLoader </SectionContent>
{...this.props} );
render={formProps => <EMSESPDevicesForm {...formProps} />} }
/>
</SectionContent>
)
}
} }
export default restController(EMSESP_DEVICES_ENDPOINT, EMSESPDevicesController); export default restController(EMSESP_DEVICES_ENDPOINT, EMSESPDevicesController);

View File

@@ -1,5 +1,10 @@
import React, { Component, Fragment } from "react"; import React, { Component, Fragment } from 'react';
import { withStyles, Theme, createStyles } from "@material-ui/core/styles"; import { withStyles, Theme, createStyles } from '@material-ui/core/styles';
import parseMilliseconds from 'parse-ms';
import { Decoder } from '@msgpack/msgpack';
const decoder = new Decoder();
import { import {
Table, Table,
@@ -18,39 +23,61 @@ import {
DialogActions, DialogActions,
Box, Box,
Dialog, Dialog,
Typography, Typography
} from "@material-ui/core"; } from '@material-ui/core';
import RefreshIcon from "@material-ui/icons/Refresh"; import RefreshIcon from '@material-ui/icons/Refresh';
import ListIcon from "@material-ui/icons/List"; import ListIcon from '@material-ui/icons/List';
import IconButton from '@material-ui/core/IconButton';
import EditIcon from '@material-ui/icons/Edit';
import { import {
redirectingAuthorizedFetch, redirectingAuthorizedFetch,
withAuthenticatedContext, withAuthenticatedContext,
AuthenticatedContextProps, AuthenticatedContextProps
} from "../authentication"; } from '../authentication';
import { RestFormProps, FormButton } from "../components"; import { RestFormProps, FormButton, extractEventValue } from '../components';
import { EMSESPDevices, EMSESPDeviceData, Device } from "./EMSESPtypes"; import {
EMSESPDevices,
EMSESPDeviceData,
Device,
DeviceValue,
DeviceValueUOM,
DeviceValueUOM_s
} from './EMSESPtypes';
import { ENDPOINT_ROOT } from "../api"; import ValueForm from './ValueForm';
export const SCANDEVICES_ENDPOINT = ENDPOINT_ROOT + "scanDevices"; import { ENDPOINT_ROOT } from '../api';
export const DEVICE_DATA_ENDPOINT = ENDPOINT_ROOT + "deviceData";
export const SCANDEVICES_ENDPOINT = ENDPOINT_ROOT + 'scanDevices';
export const DEVICE_DATA_ENDPOINT = ENDPOINT_ROOT + 'deviceData';
export const WRITE_VALUE_ENDPOINT = ENDPOINT_ROOT + 'writeValue';
const StyledTableCell = withStyles((theme: Theme) => const StyledTableCell = withStyles((theme: Theme) =>
createStyles({ createStyles({
head: { head: {
backgroundColor: theme.palette.common.black, backgroundColor: theme.palette.common.black,
color: theme.palette.common.white, color: theme.palette.common.white
}, },
body: { body: {
fontSize: 14, fontSize: 14
}, }
}) })
)(TableCell); )(TableCell);
const CustomTooltip = withStyles((theme: Theme) => ({
tooltip: {
backgroundColor: theme.palette.secondary.main,
color: 'white',
boxShadow: theme.shadows[1],
fontSize: 11,
border: '1px solid #dadde9'
}
}))(Tooltip);
function compareDevices(a: Device, b: Device) { function compareDevices(a: Device, b: Device) {
if (a.type < b.type) { if (a.type < b.type) {
return -1; return -1;
@@ -65,33 +92,118 @@ interface EMSESPDevicesFormState {
confirmScanDevices: boolean; confirmScanDevices: boolean;
processing: boolean; processing: boolean;
deviceData?: EMSESPDeviceData; deviceData?: EMSESPDeviceData;
selectedDevice?: number;
edit_devicevalue?: DeviceValue;
} }
type EMSESPDevicesFormProps = RestFormProps<EMSESPDevices> & type EMSESPDevicesFormProps = RestFormProps<EMSESPDevices> &
AuthenticatedContextProps & AuthenticatedContextProps &
WithWidthProps; WithWidthProps;
function formatTemp(t: string) { export const formatDuration = (duration_min: number) => {
if (t == null) { const { days, hours, minutes } = parseMilliseconds(duration_min * 60000);
return "(not available)"; let formatted = '';
if (days) {
formatted += pluralize(days, 'day');
} }
return t + " °C"; if (hours) {
} formatted += pluralize(hours, 'hour');
}
if (minutes) {
formatted += pluralize(minutes, 'minute');
}
return formatted;
};
function formatUnit(u: string) { const pluralize = (count: number, noun: string, suffix = 's') =>
if (u == null) { ` ${count} ${noun}${count !== 1 ? suffix : ''} `;
return u;
function formatValue(value: any, uom: number) {
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDuration(value * 60) : '0 hours';
case DeviceValueUOM.MINUTES:
return value ? formatDuration(value) : '0 minutes';
case DeviceValueUOM.NONE:
return value;
case DeviceValueUOM.NUM:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.BOOLEAN:
return value ? 'on' : 'off';
default:
return (
new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]
);
} }
return " " + u;
} }
class EMSESPDevicesForm extends Component< class EMSESPDevicesForm extends Component<
EMSESPDevicesFormProps, EMSESPDevicesFormProps,
EMSESPDevicesFormState EMSESPDevicesFormState
> { > {
state: EMSESPDevicesFormState = { state: EMSESPDevicesFormState = {
confirmScanDevices: false, confirmScanDevices: false,
processing: false, processing: false
};
handleValueChange = (name: keyof DeviceValue) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
this.setState({
edit_devicevalue: {
...this.state.edit_devicevalue!,
[name]: extractEventValue(event)
}
});
};
cancelEditingValue = () => {
this.setState({ edit_devicevalue: undefined });
};
doneEditingValue = () => {
const { edit_devicevalue, selectedDevice } = this.state;
redirectingAuthorizedFetch(WRITE_VALUE_ENDPOINT, {
method: 'POST',
body: JSON.stringify({
id: selectedDevice,
devicevalue: edit_devicevalue
}),
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.status === 200) {
this.props.enqueueSnackbar('Write command sent to device', {
variant: 'success'
});
} else if (response.status === 204) {
this.props.enqueueSnackbar('Write command failed', {
variant: 'error'
});
} else if (response.status === 403) {
this.props.enqueueSnackbar('Write access denied', {
variant: 'error'
});
} else {
throw Error('Unexpected response code: ' + response.status);
}
})
.catch((error) => {
this.props.enqueueSnackbar(error.message || 'Problem writing value', {
variant: 'error'
});
});
if (edit_devicevalue) {
this.setState({ edit_devicevalue: undefined });
}
};
sendCommand = (dv: DeviceValue) => {
this.setState({ edit_devicevalue: dv });
}; };
noDevices = () => { noDevices = () => {
@@ -106,7 +218,7 @@ class EMSESPDevicesForm extends Component<
return (this.state.deviceData?.data || []).length === 0; return (this.state.deviceData?.data || []).length === 0;
}; };
createDeviceItems() { renderDeviceItems() {
const { width, data } = this.props; const { width, data } = this.props;
return ( return (
<TableContainer> <TableContainer>
@@ -117,30 +229,27 @@ class EMSESPDevicesForm extends Component<
{!this.noDevices() && ( {!this.noDevices() && (
<Table <Table
size="small" size="small"
padding={isWidthDown("xs", width!) ? "none" : "default"} padding={isWidthDown('xs', width!) ? 'none' : 'default'}
> >
<TableHead>
<TableRow>
<StyledTableCell>Type</StyledTableCell>
<StyledTableCell align="center">Brand</StyledTableCell>
<StyledTableCell align="center">Model</StyledTableCell>
<StyledTableCell align="center">Device ID</StyledTableCell>
<StyledTableCell align="center">Product ID</StyledTableCell>
<StyledTableCell align="center">Version</StyledTableCell>
<StyledTableCell></StyledTableCell>
</TableRow>
</TableHead>
<TableBody> <TableBody>
{data.devices.sort(compareDevices).map((device) => ( {data.devices.sort(compareDevices).map((device) => (
<TableRow <TableRow
hover hover
key={device.id} key={device.id}
onClick={() => this.handleRowClick(device.id)} onClick={() => this.handleRowClick(device)}
> >
<TableCell component="th" scope="row"> <TableCell>
<Tooltip <CustomTooltip
title="click for details..." title={
arrow 'DeviceID:0x' +
(
'00' + device.deviceid.toString(16).toUpperCase()
).slice(-2) +
' ProductID:' +
device.productid +
' Version:' +
device.version
}
placement="right-end" placement="right-end"
> >
<Button <Button
@@ -150,19 +259,11 @@ class EMSESPDevicesForm extends Component<
> >
{device.type} {device.type}
</Button> </Button>
</Tooltip> </CustomTooltip>
</TableCell> </TableCell>
<TableCell align="center">{device.brand}</TableCell> <TableCell align="right">
<TableCell align="center">{device.name}</TableCell> {device.brand + ' ' + device.name}{' '}
<TableCell align="center">
0x
{("00" + device.deviceid.toString(16).toUpperCase()).slice(
-2
)}
</TableCell> </TableCell>
<TableCell align="center">{device.productid}</TableCell>
<TableCell align="center">{device.version}</TableCell>
<TableCell></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -186,7 +287,7 @@ class EMSESPDevicesForm extends Component<
); );
} }
createSensorItems() { renderSensorItems() {
const { data } = this.props; const { data } = this.props;
return ( return (
<TableContainer> <TableContainer>
@@ -211,7 +312,7 @@ class EMSESPDevicesForm extends Component<
</TableCell> </TableCell>
<TableCell align="center">{sensorData.id}</TableCell> <TableCell align="center">{sensorData.id}</TableCell>
<TableCell align="right"> <TableCell align="right">
{formatTemp(sensorData.temp)} {formatValue(sensorData.temp, DeviceValueUOM.DEGREES)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -234,10 +335,12 @@ class EMSESPDevicesForm extends Component<
<Dialog <Dialog
open={this.state.confirmScanDevices} open={this.state.confirmScanDevices}
onClose={this.onScanDevicesRejected} onClose={this.onScanDevicesRejected}
fullWidth
maxWidth="sm"
> >
<DialogTitle>Confirm Scan Devices</DialogTitle> <DialogTitle>Confirm Scan Devices</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
Are you sure you want to initiate a scan on the EMS bus for all new Are you sure you want to start a scan on the EMS bus for all new
devices? devices?
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -276,45 +379,45 @@ class EMSESPDevicesForm extends Component<
redirectingAuthorizedFetch(SCANDEVICES_ENDPOINT) redirectingAuthorizedFetch(SCANDEVICES_ENDPOINT)
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
this.props.enqueueSnackbar("Device scan is starting...", { this.props.enqueueSnackbar('Device scan is starting...', {
variant: "info", variant: 'info'
}); });
this.setState({ processing: false, confirmScanDevices: false }); this.setState({ processing: false, confirmScanDevices: false });
} else { } else {
throw Error("Invalid status code: " + response.status); throw Error('Invalid status code: ' + response.status);
} }
}) })
.catch((error) => { .catch((error) => {
this.props.enqueueSnackbar(error.message || "Problem with scan", { this.props.enqueueSnackbar(error.message || 'Problem with scan', {
variant: "error", variant: 'error'
}); });
this.setState({ processing: false, confirmScanDevices: false }); this.setState({ processing: false, confirmScanDevices: false });
}); });
}; };
handleRowClick = (id: any) => { handleRowClick = (device: any) => {
this.setState({ deviceData: undefined }); this.setState({ selectedDevice: device.id, deviceData: undefined });
redirectingAuthorizedFetch(DEVICE_DATA_ENDPOINT, { redirectingAuthorizedFetch(DEVICE_DATA_ENDPOINT, {
method: "POST", method: 'POST',
body: JSON.stringify({ id: id }), body: JSON.stringify({ id: device.id }),
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, }
}) })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.arrayBuffer();
// this.setState({ errorMessage: undefined }, this.props.loadData);
} }
throw Error("Unexpected response code: " + response.status); throw Error('Unexpected response code: ' + response.status);
}) })
.then((json) => { .then((arrayBuffer) => {
const json: any = decoder.decode(arrayBuffer);
this.setState({ deviceData: json }); this.setState({ deviceData: json });
}) })
.catch((error) => { .catch((error) => {
this.props.enqueueSnackbar( this.props.enqueueSnackbar(
error.message || "Problem getting device data", error.message || 'Problem getting device data',
{ variant: "error" } { variant: 'error' }
); );
this.setState({ deviceData: undefined }); this.setState({ deviceData: undefined });
}); });
@@ -323,6 +426,7 @@ class EMSESPDevicesForm extends Component<
renderDeviceData() { renderDeviceData() {
const { deviceData } = this.state; const { deviceData } = this.state;
const { width } = this.props; const { width } = this.props;
const me = this.props.authenticatedContext.me;
if (this.noDevices()) { if (this.noDevices()) {
return; return;
@@ -344,44 +448,60 @@ class EMSESPDevicesForm extends Component<
<TableContainer> <TableContainer>
<Table <Table
size="small" size="small"
padding={isWidthDown("xs", width!) ? "none" : "default"} padding={isWidthDown('xs', width!) ? 'none' : 'default'}
> >
<TableHead></TableHead> <TableHead></TableHead>
<TableBody> <TableBody>
{deviceData.data.map((item, i) => { {deviceData.data.map((item, i) => (
if (i % 3) { <TableRow hover key={i}>
return null; <TableCell padding="checkbox" style={{ width: 18 }}>
} else { {item.c && me.admin && (
return ( <CustomTooltip
<TableRow key={i}> title="change value"
<TableCell component="th" scope="row">{deviceData.data[i + 2]}</TableCell> placement="left-end"
<TableCell align="right">{deviceData.data[i]}{formatUnit(deviceData.data[i + 1])}</TableCell> >
</TableRow> <IconButton
); edge="start"
} size="small"
})} aria-label="Edit"
onClick={() => this.sendCommand(item)}
>
<EditIcon color="primary" fontSize="small" />
</IconButton>
</CustomTooltip>
)}
</TableCell>
<TableCell padding="none" component="th" scope="row">
{item.n}
</TableCell>
<TableCell padding="none" align="right">
{formatValue(item.v, item.u)}
</TableCell>
</TableRow>
))}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
)} )}
{this.noDeviceData() && ( {this.noDeviceData() && (
<Box color="warning.main" p={0} mt={0} mb={0}> <Box color="warning.main" p={0} mt={0} mb={0}>
<Typography variant="body1"> <Typography variant="body1">
<i>No data available for this device</i> <i>No data available for this device</i>
</Typography> </Typography>
</Box> </Box>
)} )}
</Fragment > </Fragment>
); );
} }
render() { render() {
const { edit_devicevalue } = this.state;
return ( return (
<Fragment> <Fragment>
<br></br> <br></br>
{this.createDeviceItems()} {this.renderDeviceItems()}
{this.renderDeviceData()} {this.renderDeviceData()}
{this.createSensorItems()} {this.renderSensorItems()}
<br></br> <br></br>
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1} padding={1}> <Box flexGrow={1} padding={1}>
@@ -398,7 +518,6 @@ class EMSESPDevicesForm extends Component<
<FormButton <FormButton
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
variant="contained" variant="contained"
color="primary"
onClick={this.onScanDevices} onClick={this.onScanDevices}
> >
Scan Devices Scan Devices
@@ -406,6 +525,14 @@ class EMSESPDevicesForm extends Component<
</Box> </Box>
</Box> </Box>
{this.renderScanDevicesDialog()} {this.renderScanDevicesDialog()}
{edit_devicevalue && (
<ValueForm
devicevalue={edit_devicevalue}
onDoneEditing={this.doneEditingValue}
onCancelEditing={this.cancelEditingValue}
handleValueChange={this.handleValueChange}
/>
)}
</Fragment> </Fragment>
); );
} }

View File

@@ -1,85 +1,110 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Typography, Box, List, ListItem, ListItemText, Link, ListItemAvatar } from '@material-ui/core'; import {
Typography,
Box,
List,
ListItem,
ListItemText,
Link,
ListItemAvatar
} from '@material-ui/core';
import { SectionContent } from '../components'; import { SectionContent } from '../components';
import CommentIcon from "@material-ui/icons/CommentTwoTone"; import CommentIcon from '@material-ui/icons/CommentTwoTone';
import MenuBookIcon from "@material-ui/icons/MenuBookTwoTone"; import MenuBookIcon from '@material-ui/icons/MenuBookTwoTone';
import GitHubIcon from "@material-ui/icons/GitHub"; import GitHubIcon from '@material-ui/icons/GitHub';
import StarIcon from "@material-ui/icons/Star"; import StarIcon from '@material-ui/icons/Star';
import ImportExportIcon from "@material-ui/icons/ImportExport"; import ImportExportIcon from '@material-ui/icons/ImportExport';
import BugReportIcon from "@material-ui/icons/BugReportTwoTone"; import BugReportIcon from '@material-ui/icons/BugReportTwoTone';
export const WebAPISystemSettings = window.location.origin + "/api?device=system&cmd=settings"; export const WebAPISystemSettings =
export const WebAPISystemInfo = window.location.origin + "/api?device=system&cmd=info"; window.location.origin + '/api/system/settings';
export const WebAPISystemInfo = window.location.origin + '/api/system/info';
class EMSESPHelp extends Component { class EMSESPHelp extends Component {
render() {
return (
<SectionContent title="EMS-ESP Help" titleGutter>
<List>
<ListItem>
<ListItemAvatar>
<MenuBookIcon />
</ListItemAvatar>
<ListItemText>
For the latest news and updates go to the{' '}
<Link href="https://emsesp.github.io/docs" color="primary">
{'official documentation'}&nbsp;website
</Link>
</ListItemText>
</ListItem>
render() { <ListItem>
return ( <ListItemAvatar>
<SectionContent title='EMS-ESP Help' titleGutter> <CommentIcon />
</ListItemAvatar>
<ListItemText>
For live community chat join our{' '}
<Link href="https://discord.gg/3J3GgnzpyT" color="primary">
{'Discord'}&nbsp;server
</Link>
</ListItemText>
</ListItem>
<List> <ListItem>
<ListItemAvatar>
<GitHubIcon />
</ListItemAvatar>
<ListItemText>
To report an issue or feature request go to{' '}
<Link
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
color="primary"
>
{'click here'}
</Link>
</ListItemText>
</ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<MenuBookIcon /> <ImportExportIcon />
</ListItemAvatar> </ListItemAvatar>
<ListItemText> <ListItemText>
For the latest news and updates go to the <Link href="https://emsesp.github.io/docs" color="primary">{'official documentation'}&nbsp;website</Link> To export your system settings{' '}
</ListItemText> <Link target="_blank" href={WebAPISystemSettings} color="primary">
</ListItem> {'click here'}
</Link>
</ListItemText>
</ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<CommentIcon /> <BugReportIcon />
</ListItemAvatar> </ListItemAvatar>
<ListItemText> <ListItemText>
For live community chat join our <Link href="https://discord.gg/3J3GgnzpyT" color="primary">{'Discord'}&nbsp;server</Link> To export the current status of EMS-ESP{' '}
</ListItemText> <Link target="_blank" href={WebAPISystemInfo} color="primary">
</ListItem> {'click here'}
</Link>
<ListItem> </ListItemText>
<ListItemAvatar> </ListItem>
<GitHubIcon /> </List>
</ListItemAvatar>
<ListItemText>
To report an issue or feature request go to <Link href="https://github.com/emsesp/EMS-ESP32/issues/new/choose" color="primary">{'click here'}</Link>
</ListItemText>
</ListItem>
<ListItem>
<ListItemAvatar>
<ImportExportIcon />
</ListItemAvatar>
<ListItemText>
To list your system settings <Link target="_blank" href={WebAPISystemSettings} color="primary">{'click here'}</Link>
</ListItemText>
</ListItem>
<ListItem>
<ListItemAvatar>
<BugReportIcon />
</ListItemAvatar>
<ListItemText>
To create a report of the current EMS-ESP status <Link target="_blank" href={WebAPISystemInfo} color="primary">{'click here'}</Link>
</ListItemText>
</ListItem>
</List>
<Box bgcolor="info.main" border={1} p={3} mt={1} mb={0}>
<Typography variant="h6">
EMS-ESP is free and open-source.
<br></br>Please consider supporting this project by giving it a <StarIcon style={{ color: '#fdff3a' }} /> on our <Link href="https://github.com/emsesp/EMS-ESP32" color="primary">{'GitHub page'}</Link>.
</Typography>
</Box>
<br></br>
</SectionContent>
)
}
<Box bgcolor="info.main" border={1} p={3} mt={1} mb={0}>
<Typography variant="h6">
EMS-ESP is free and open-source.
<br></br>Please consider supporting this project by giving it a{' '}
<StarIcon style={{ color: '#fdff3a' }} /> on our{' '}
<Link href="https://github.com/emsesp/EMS-ESP32" color="primary">
{'GitHub page'}
</Link>
.
</Typography>
</Box>
<br></br>
</SectionContent>
);
}
} }
export default EMSESPHelp; export default EMSESPHelp;

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
@@ -10,26 +10,31 @@ import { AuthenticatedRoute } from '../authentication';
import EMSESPSettingsController from './EMSESPSettingsController'; import EMSESPSettingsController from './EMSESPSettingsController';
class EMSESP extends Component<RouteComponentProps> { class EMSESP extends Component<RouteComponentProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
render() { render() {
return ( return (
<MenuAppBar sectionTitle="Settings"> <MenuAppBar sectionTitle="Settings">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value={`/${PROJECT_PATH}/settings`} label="EMS-ESP Settings" /> <Tab value={`/${PROJECT_PATH}/settings`} label="EMS-ESP Settings" />
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/settings`} component={EMSESPSettingsController} /> <AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/settings`}
component={EMSESPSettingsController}
/>
<Redirect to={`/${PROJECT_PATH}/settings`} /> <Redirect to={`/${PROJECT_PATH}/settings`} />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
);
)
} }
} }
export default EMSESP; export default EMSESP;

View File

@@ -1,321 +1,39 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { ValidatorForm, TextValidator, SelectValidator } from 'react-material-ui-form-validator';
import { Checkbox, Typography, Box, Link } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save';
import MenuItem from '@material-ui/core/MenuItem';
import { ENDPOINT_ROOT } from '../api'; import { ENDPOINT_ROOT } from '../api';
import { restController, RestControllerProps, RestFormLoader, RestFormProps, FormActions, FormButton, BlockFormControlLabel, SectionContent } from '../components'; import EMSESPSettingsForm from './EMSESPSettingsForm';
import { isIP, optional } from '../validators'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { EMSESPSettings } from './EMSESPtypes'; import { EMSESPSettings } from './EMSESPtypes';
export const EMSESP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "emsespSettings"; export const EMSESP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'emsespSettings';
type EMSESPSettingsControllerProps = RestControllerProps<EMSESPSettings>; type EMSESPSettingsControllerProps = RestControllerProps<EMSESPSettings>;
class EMSESPSettingsController extends Component<EMSESPSettingsControllerProps> { class EMSESPSettingsController extends Component<EMSESPSettingsControllerProps> {
componentDidMount() {
this.props.loadData();
}
componentDidMount() { render() {
ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
this.props.loadData();
}
render() {
return (
<SectionContent title='EMS-ESP Settings' titleGutter>
<RestFormLoader
{...this.props}
render={props => (
<EMSESPSettingsControllerForm {...props} />
)}
/>
</SectionContent>
)
}
}
export default restController(EMSESP_SETTINGS_ENDPOINT, EMSESPSettingsController);
type EMSESPSettingsControllerFormProps = RestFormProps<EMSESPSettings>;
function EMSESPSettingsControllerForm(props: EMSESPSettingsControllerFormProps) {
const { data, saveData, handleValueChange } = props;
return ( return (
<ValidatorForm onSubmit={saveData}> <SectionContent title="" titleGutter>
<Box bgcolor="info.main" p={2} mt={2} mb={2}> <RestFormLoader
<Typography variant="body1"> {...this.props}
Change the default settings on this page. For help click <Link target="_blank" href="https://emsesp.github.io/docs/#/Configure-firmware?id=settings" color="primary">{'here'}</Link>. render={(formProps) => <EMSESPSettingsForm {...formProps} />}
</Typography> />
</Box> </SectionContent>
<br></br>
<Typography variant="h6" color="primary" >
EMS Bus
</Typography>
<SelectValidator name="tx_mode"
label="Tx Mode"
value={data.tx_mode}
fullWidth
variant="outlined"
onChange={handleValueChange('tx_mode')}
margin="normal">
<MenuItem value={0}>0 - Off</MenuItem>
<MenuItem value={1}>1 - Default</MenuItem>
<MenuItem value={2}>2 - EMS+</MenuItem>
<MenuItem value={3}>3 - HT3</MenuItem>
<MenuItem value={4}>4 - Hardware</MenuItem>
</SelectValidator>
<SelectValidator name="ems_bus_id"
label="Bus ID"
value={data.ems_bus_id}
fullWidth
variant="outlined"
onChange={handleValueChange('ems_bus_id')}
margin="normal">
<MenuItem value={0x0B}>Service Key (0x0B)</MenuItem>
<MenuItem value={0x0D}>Modem (0x0D)</MenuItem>
<MenuItem value={0x0A}>Terminal (0x0A)</MenuItem>
<MenuItem value={0x0F}>Time Module (0x0F)</MenuItem>
<MenuItem value={0x12}>Alarm Module (0x12)</MenuItem>
</SelectValidator>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:40']}
errorMessages={['Rx GPIO is required', "Must be a number", "Must be 0 or higher", "Max value is 40"]}
name="rx_gpio"
label="Rx GPIO pin"
fullWidth
variant="outlined"
value={data.rx_gpio}
type="number"
onChange={handleValueChange('rx_gpio')}
margin="normal"
/>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:40']}
errorMessages={['Tx GPIO is required', "Must be a number", "Must be 0 or higher", "Max value is 40"]}
name="tx_gpio"
label="Tx GPIO pin"
fullWidth
variant="outlined"
value={data.tx_gpio}
type="number"
onChange={handleValueChange('tx_gpio')}
margin="normal"
/>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:120']}
errorMessages={['Tx delay is required', "Must be a number", "Must be 0 or higher", "Max value is 120"]}
name="tx_delay"
label="Tx delayed start (seconds)"
fullWidth
variant="outlined"
value={data.tx_delay}
type="number"
onChange={handleValueChange('tx_delay')}
margin="normal"
/>
<br></br>
<Typography variant="h6" color="primary" >
External Button
</Typography>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:40']}
errorMessages={['Button GPIO is required', "Must be a number", "Must be 0 or higher", "Max value is 40"]}
name="pbutton_gpio"
label="Button GPIO pin"
fullWidth
variant="outlined"
value={data.pbutton_gpio}
type="number"
onChange={handleValueChange('pbutton_gpio')}
margin="normal"
/>
<br></br>
<Typography variant="h6" color="primary" >
Dallas Sensor
</Typography>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:40']}
errorMessages={['Dallas GPIO is required', "Must be a number", "Must be 0 or higher", "Max value is 40"]}
name="dallas_gpio"
label="Dallas GPIO pin (0=none)"
fullWidth
variant="outlined"
value={data.dallas_gpio}
type="number"
onChange={handleValueChange('dallas_gpio')}
margin="normal"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.dallas_parasite}
onChange={handleValueChange('dallas_parasite')}
value="dallas_parasite"
/>
}
label="Dallas Parasite Mode"
/>
<br></br>
<Typography variant="h6" color="primary" >
LED
</Typography>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:40']}
errorMessages={['LED GPIO is required', "Must be a number", "Must be 0 or higher", "Max value is 40"]}
name="led_gpio"
label="LED GPIO pin (0=none)"
fullWidth
variant="outlined"
value={data.led_gpio}
type="number"
onChange={handleValueChange('led_gpio')}
margin="normal"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.hide_led}
onChange={handleValueChange('hide_led')}
value="hide_led"
/>
}
label="Invert/Hide LED"
/>
<br></br>
<Typography variant="h6" color="primary" >
Shower
</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.shower_timer}
onChange={handleValueChange('shower_timer')}
value="shower_timer"
/>
}
label="Shower Timer"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.shower_alert}
onChange={handleValueChange('shower_alert')}
value="shower_alert"
/>
}
label="Shower Alert"
/>
<br></br>
<Typography variant="h6" color="primary" >
API
</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.api_enabled}
onChange={handleValueChange('api_enabled')}
value="api_enabled"
/>
}
label="Allow WEB API to write commands"
/>
<br></br>
<Typography variant="h6" color="primary" >
Syslog
</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.syslog_enabled}
onChange={handleValueChange('syslog_enabled')}
value="syslog_enabled"
/>
}
label="Enable Syslog"
/>
<TextValidator
validators={['isOptionalIP']}
errorMessages={["Not a valid IP address"]}
name="syslog_host"
label="Syslog IP"
fullWidth
variant="outlined"
value={data.syslog_host}
onChange={handleValueChange('syslog_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="syslog_port"
label="Syslog Port (default 514)"
fullWidth
variant="outlined"
value={data.syslog_port}
type="number"
onChange={handleValueChange('syslog_port')}
margin="normal"
/>
<SelectValidator name="syslog_level"
label="Syslog Log Level"
value={data.syslog_level}
fullWidth
variant="outlined"
onChange={handleValueChange('syslog_level')}
margin="normal">
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={8}>ALL</MenuItem>
</SelectValidator>
<TextValidator
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']}
errorMessages={['Syslog Mark is required', "Must be a number", "Must be 0 or higher", "Max value is 10"]}
name="syslog_mark_interval"
label="Syslog Mark Interval (seconds, 0=off)"
fullWidth
variant="outlined"
value={data.syslog_mark_interval}
type="number"
onChange={handleValueChange('syslog_mark_interval')}
margin="normal"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.trace_raw}
onChange={handleValueChange('trace_raw')}
value="trace_raw"
/>
}
label="Trace EMS telegrams in raw format"
/>
<br></br>
<Typography variant="h6" color="primary" >
Analog Input
</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.analog_enabled}
onChange={handleValueChange('analog_enabled')}
value="analog_enabled"
/>
}
label="Enable ADC"
/>
<br></br>
<FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
Save
</FormButton>
</FormActions>
</ValidatorForm>
); );
}
} }
export default restController(
EMSESP_SETTINGS_ENDPOINT,
EMSESPSettingsController
);

View File

@@ -0,0 +1,578 @@
import { Component } from 'react';
import {
ValidatorForm,
TextValidator,
SelectValidator
} from 'react-material-ui-form-validator';
import {
Checkbox,
Typography,
Box,
Link,
withWidth,
WithWidthProps,
Grid
} from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save';
import MenuItem from '@material-ui/core/MenuItem';
import {
redirectingAuthorizedFetch,
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import {
RestFormProps,
FormActions,
FormButton,
BlockFormControlLabel
} from '../components';
import { isIP, optional } from '../validators';
import { EMSESPSettings } from './EMSESPtypes';
import { boardProfileSelectItems } from './EMSESPBoardProfiles';
import { ENDPOINT_ROOT } from '../api';
export const BOARD_PROFILE_ENDPOINT = ENDPOINT_ROOT + 'boardProfile';
type EMSESPSettingsFormProps = RestFormProps<EMSESPSettings> &
AuthenticatedContextProps &
WithWidthProps;
interface EMSESPSettingsFormState {
processing: boolean;
}
class EMSESPSettingsForm extends Component<EMSESPSettingsFormProps> {
state: EMSESPSettingsFormState = {
processing: false
};
componentDidMount() {
ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
}
changeBoardProfile = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { data, setData } = this.props;
setData({
...data,
board_profile: event.target.value
});
if (event.target.value === 'CUSTOM') return;
this.setState({ processing: true });
redirectingAuthorizedFetch(BOARD_PROFILE_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ code: event.target.value }),
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.status === 200) {
return response.json();
}
throw Error('Unexpected response code: ' + response.status);
})
.then((json) => {
this.props.enqueueSnackbar('Profile loaded', { variant: 'success' });
setData({
...data,
led_gpio: json.led_gpio,
dallas_gpio: json.dallas_gpio,
rx_gpio: json.rx_gpio,
tx_gpio: json.tx_gpio,
pbutton_gpio: json.pbutton_gpio,
board_profile: event.target.value
});
this.setState({ processing: false });
})
.catch((error) => {
this.props.enqueueSnackbar(
error.message || 'Problem fetching board profile',
{ variant: 'warning' }
);
this.setState({ processing: false });
});
};
render() {
const { data, saveData, handleValueChange } = this.props;
return (
<ValidatorForm onSubmit={saveData}>
<Box bgcolor="info.main" p={2} mt={2} mb={2}>
<Typography variant="body1">
Adjust any of the EMS-ESP settings here. For help refer to the{' '}
<Link
target="_blank"
href="https://emsesp.github.io/docs/#/Configure-firmware32?id=ems-esp-settings"
color="primary"
>
{'online documentation'}
</Link>
.
</Typography>
</Box>
<br></br>
<Typography variant="h6" color="primary">
EMS Bus
</Typography>
<Grid
container
spacing={1}
direction="row"
justify="flex-start"
alignItems="flex-start"
>
<Grid item xs={5}>
<SelectValidator
name="tx_mode"
label="Tx Mode"
value={data.tx_mode}
fullWidth
variant="outlined"
onChange={handleValueChange('tx_mode')}
margin="normal"
>
<MenuItem value={0}>Off</MenuItem>
<MenuItem value={1}>EMS</MenuItem>
<MenuItem value={2}>EMS+</MenuItem>
<MenuItem value={3}>HT3</MenuItem>
<MenuItem value={4}>Hardware</MenuItem>
</SelectValidator>
</Grid>
<Grid item xs={6}>
<SelectValidator
name="ems_bus_id"
label="Bus ID"
value={data.ems_bus_id}
fullWidth
variant="outlined"
onChange={handleValueChange('ems_bus_id')}
margin="normal"
>
<MenuItem value={0x0b}>Service Key (0x0B)</MenuItem>
<MenuItem value={0x0d}>Modem (0x0D)</MenuItem>
<MenuItem value={0x0a}>Terminal (0x0A)</MenuItem>
<MenuItem value={0x0f}>Time Module (0x0F)</MenuItem>
<MenuItem value={0x12}>Alarm Module (0x12)</MenuItem>
</SelectValidator>
</Grid>
<Grid item xs={6}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:120'
]}
errorMessages={[
'Tx delay is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 120'
]}
name="tx_delay"
label="Tx start delay (seconds)"
fullWidth
variant="outlined"
value={data.tx_delay}
type="number"
onChange={handleValueChange('tx_delay')}
margin="normal"
/>
</Grid>
</Grid>
<br></br>
<Typography variant="h6" color="primary">
Board Profile
</Typography>
<Box color="warning.main" p={0} mt={0} mb={0}>
<Typography variant="body2">
<i>
Select a pre-configured board layout to automatically set the GPIO
pins, or set your own custom configuration
</i>
</Typography>
</Box>
<SelectValidator
name="board_profile"
label="Board Profile"
value={data.board_profile}
fullWidth
variant="outlined"
onChange={this.changeBoardProfile}
margin="normal"
>
{boardProfileSelectItems()}
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
Custom...
</MenuItem>
</SelectValidator>
{data.board_profile === 'CUSTOM' && (
<Grid
container
spacing={1}
direction="row"
justify="flex-start"
alignItems="flex-start"
>
<Grid item xs={4}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:40',
'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]}
errorMessages={[
'GPIO is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 40',
'Not a valid GPIO'
]}
name="rx_gpio"
label="Rx GPIO"
fullWidth
variant="outlined"
value={data.rx_gpio}
type="number"
onChange={handleValueChange('rx_gpio')}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:40',
'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]}
errorMessages={[
'GPIO is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 40',
'Not a valid GPIO'
]}
name="tx_gpio"
label="Tx GPIO"
fullWidth
variant="outlined"
value={data.tx_gpio}
type="number"
onChange={handleValueChange('tx_gpio')}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:40',
'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]}
errorMessages={[
'GPIO is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 40',
'Not a valid GPIO'
]}
name="pbutton_gpio"
label="Button GPIO"
fullWidth
variant="outlined"
value={data.pbutton_gpio}
type="number"
onChange={handleValueChange('pbutton_gpio')}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:40',
'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]}
errorMessages={[
'GPIO is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 40',
'Not a valid GPIO'
]}
name="dallas_gpio"
label="Dallas GPIO (0=none)"
fullWidth
variant="outlined"
value={data.dallas_gpio}
type="number"
onChange={handleValueChange('dallas_gpio')}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:40',
'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]}
errorMessages={[
'GPIO is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 40',
'Not a valid GPIO'
]}
name="led_gpio"
label="LED GPIO (0=none)"
fullWidth
variant="outlined"
value={data.led_gpio}
type="number"
onChange={handleValueChange('led_gpio')}
margin="normal"
/>
</Grid>
</Grid>
)}
<br></br>
<Typography variant="h6" color="primary">
Options
</Typography>
{data.led_gpio !== 0 && (
<BlockFormControlLabel
control={
<Checkbox
checked={data.hide_led}
onChange={handleValueChange('hide_led')}
value="hide_led"
/>
}
label="Hide LED"
/>
)}
{data.dallas_gpio !== 0 && (
<BlockFormControlLabel
control={
<Checkbox
checked={data.dallas_parasite}
onChange={handleValueChange('dallas_parasite')}
value="dallas_parasite"
/>
}
label="Enable Dallas parasite mode"
/>
)}
<BlockFormControlLabel
control={
<Checkbox
checked={data.notoken_api}
onChange={handleValueChange('notoken_api')}
value="notoken_api"
/>
}
label="Bypass Access Token authorization on API calls"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.analog_enabled}
onChange={handleValueChange('analog_enabled')}
value="analog_enabled"
/>
}
label="Enable ADC"
/>
<Grid
container
spacing={0}
direction="row"
justify="flex-start"
alignItems="flex-start"
>
<BlockFormControlLabel
control={
<Checkbox
checked={data.shower_timer}
onChange={handleValueChange('shower_timer')}
value="shower_timer"
/>
}
label="Enable Shower Timer"
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.shower_alert}
onChange={handleValueChange('shower_alert')}
value="shower_alert"
/>
}
label="Enable Shower Alert"
/>
</Grid>
<br></br>
<Typography variant="h6" color="primary">
Syslog
</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.syslog_enabled}
onChange={handleValueChange('syslog_enabled')}
value="syslog_enabled"
/>
}
label="Enable Syslog"
/>
{data.syslog_enabled && (
<Grid
container
spacing={1}
direction="row"
justify="flex-start"
alignItems="flex-start"
>
<Grid item xs={5}>
<TextValidator
validators={['isOptionalIP']}
errorMessages={['Not a valid IP address']}
name="syslog_host"
label="IP"
fullWidth
variant="outlined"
value={data.syslog_host}
onChange={handleValueChange('syslog_host')}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<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="syslog_port"
label="Port"
fullWidth
variant="outlined"
value={data.syslog_port}
type="number"
onChange={handleValueChange('syslog_port')}
margin="normal"
/>
</Grid>
<Grid item xs={5}>
<SelectValidator
name="syslog_level"
label="Log Level"
value={data.syslog_level}
fullWidth
variant="outlined"
onChange={handleValueChange('syslog_level')}
margin="normal"
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={8}>ALL</MenuItem>
</SelectValidator>
</Grid>
<Grid item xs={6}>
<TextValidator
validators={[
'required',
'isNumber',
'minNumber:0',
'maxNumber:65535'
]}
errorMessages={[
'Syslog Mark is required',
'Must be a number',
'Must be 0 or higher',
'Max value is 10'
]}
name="syslog_mark_interval"
label="Mark Interval seconds (0=off)"
fullWidth
variant="outlined"
value={data.syslog_mark_interval}
type="number"
onChange={handleValueChange('syslog_mark_interval')}
margin="normal"
/>
</Grid>
<BlockFormControlLabel
control={
<Checkbox
checked={data.trace_raw}
onChange={handleValueChange('trace_raw')}
value="trace_raw"
/>
}
label="Output EMS telegrams in raw format"
/>
</Grid>
)}
<br></br>
<FormActions>
<FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save
</FormButton>
</FormActions>
</ValidatorForm>
);
}
}
export default withAuthenticatedContext(withWidth()(EMSESPSettingsForm));

View File

@@ -1,10 +1,10 @@
import { Theme } from '@material-ui/core'; import { Theme } from '@material-ui/core';
import { EMSESPStatus, busConnectionStatus } from './EMSESPtypes'; import { EMSESPStatus, busConnectionStatus } from './EMSESPtypes';
export const isConnected = ({ status }: EMSESPStatus) => status !== busConnectionStatus.BUS_STATUS_OFFLINE; export const isConnected = ({ status }: EMSESPStatus) =>
status !== busConnectionStatus.BUS_STATUS_OFFLINE;
export const busStatusHighlight = ({ status }: EMSESPStatus, theme: Theme) => { export const busStatusHighlight = ({ status }: EMSESPStatus, theme: Theme) => {
switch (status) { switch (status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -15,26 +15,25 @@ export const busStatusHighlight = ({ status }: EMSESPStatus, theme: Theme) => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
} };
export const busStatus = ({ status }: EMSESPStatus) => { export const busStatus = ({ status }: EMSESPStatus) => {
switch (status) { switch (status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return "Connected"; return 'Connected';
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return "Tx Errors"; return 'Tx Errors';
case busConnectionStatus.BUS_STATUS_OFFLINE: case busConnectionStatus.BUS_STATUS_OFFLINE:
return "Disconnected"; return 'Disconnected';
default: default:
return "Unknown"; return 'Unknown';
} }
} };
export const qualityHighlight = ( value: number, theme: Theme) => { export const qualityHighlight = (value: number, theme: Theme) => {
if (value >= 95) { if (value >= 95) {
return theme.palette.success.main; return theme.palette.success.main;
} }
return theme.palette.error.main; return theme.palette.error.main;
} };

View File

@@ -1,30 +1,34 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { ENDPOINT_ROOT } from '../api'; import { ENDPOINT_ROOT } from '../api';
import EMSESPStatusForm from './EMSESPStatusForm'; import EMSESPStatusForm from './EMSESPStatusForm';
import { EMSESPStatus } from './EMSESPtypes'; import { EMSESPStatus } from './EMSESPtypes';
export const EMSESP_STATUS_ENDPOINT = ENDPOINT_ROOT + "emsespStatus"; export const EMSESP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'emsespStatus';
type EMSESPStatusControllerProps = RestControllerProps<EMSESPStatus>; type EMSESPStatusControllerProps = RestControllerProps<EMSESPStatus>;
class EMSESPStatusController extends Component<EMSESPStatusControllerProps> { class EMSESPStatusController extends Component<EMSESPStatusControllerProps> {
componentDidMount() {
this.props.loadData();
}
componentDidMount() { render() {
this.props.loadData(); return (
} <SectionContent title="EMS Status" titleGutter>
<RestFormLoader
render() { {...this.props}
return ( render={(formProps) => <EMSESPStatusForm {...formProps} />}
<SectionContent title="EMS Status"> />
<RestFormLoader </SectionContent>
{...this.props} );
render={formProps => <EMSESPStatusForm {...formProps} />} }
/>
</SectionContent>
)
}
} }
export default restController(EMSESP_STATUS_ENDPOINT, EMSESPStatusController); export default restController(EMSESP_STATUS_ENDPOINT, EMSESPStatusController);

View File

@@ -1,6 +1,6 @@
import React, { Component, Fragment } from "react"; import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from "@material-ui/core/styles"; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { import {
TableContainer, TableContainer,
Table, Table,
@@ -13,35 +13,32 @@ import {
ListItemText, ListItemText,
withWidth, withWidth,
WithWidthProps, WithWidthProps,
isWidthDown, isWidthDown
} from "@material-ui/core"; } from '@material-ui/core';
import RefreshIcon from "@material-ui/icons/Refresh"; import RefreshIcon from '@material-ui/icons/Refresh';
import DeviceHubIcon from "@material-ui/icons/DeviceHub"; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import { import {
RestFormProps, RestFormProps,
FormActions, FormActions,
FormButton, FormButton,
HighlightAvatar, HighlightAvatar
} from "../components"; } from '../components';
import { import { busStatus, busStatusHighlight, isConnected } from './EMSESPStatus';
busStatus,
busStatusHighlight,
isConnected,
} from "./EMSESPStatus";
import { EMSESPStatus } from "./EMSESPtypes"; import { EMSESPStatus } from './EMSESPtypes';
function formatNumber(num: number) { function formatNumber(num: number) {
return new Intl.NumberFormat().format(num); return new Intl.NumberFormat().format(num);
} }
type EMSESPStatusFormProps = RestFormProps<EMSESPStatus> & WithTheme & WithWidthProps; type EMSESPStatusFormProps = RestFormProps<EMSESPStatus> &
WithTheme &
WithWidthProps;
class EMSESPStatusForm extends Component<EMSESPStatusFormProps> { class EMSESPStatusForm extends Component<EMSESPStatusFormProps> {
createListItems() { createListItems() {
const { data, theme, width } = this.props; const { data, theme, width } = this.props;
return ( return (
@@ -52,24 +49,30 @@ class EMSESPStatusForm extends Component<EMSESPStatusFormProps> {
<DeviceHubIcon /> <DeviceHubIcon />
</HighlightAvatar> </HighlightAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Connection Status" secondary={busStatus(data)} /> <ListItemText
primary="Connection Status"
secondary={busStatus(data)}
/>
</ListItem> </ListItem>
{isConnected(data) && ( {isConnected(data) && (
<TableContainer> <TableContainer>
<Table size="small" padding={isWidthDown('xs', width!) ? "none" : "default"}> <Table
size="small"
padding={isWidthDown('xs', width!) ? 'none' : 'default'}
>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell> <TableCell># Telegrams Received</TableCell>
# Telegrams Received <TableCell align="right">
</TableCell> {formatNumber(data.rx_received)}&nbsp;(quality{' '}
<TableCell align="right">{formatNumber(data.rx_received)}&nbsp;({data.rx_quality}%) {data.rx_quality}%)
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell > <TableCell># Telegrams Sent</TableCell>
# Telegrams Sent <TableCell align="right">
</TableCell > {formatNumber(data.tx_sent)}&nbsp;(quality {data.tx_quality}
<TableCell align="right">{formatNumber(data.tx_sent)}&nbsp;({data.tx_quality}%) %)
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
@@ -86,7 +89,11 @@ class EMSESPStatusForm extends Component<EMSESPStatusFormProps> {
<List>{this.createListItems()}</List> <List>{this.createListItems()}</List>
<FormActions> <FormActions>
<FormButton <FormButton
startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

@@ -16,10 +16,11 @@ export interface EMSESPSettings {
dallas_parasite: boolean; dallas_parasite: boolean;
led_gpio: number; led_gpio: number;
hide_led: boolean; hide_led: boolean;
api_enabled: boolean; notoken_api: boolean;
analog_enabled: boolean; analog_enabled: boolean;
pbutton_gpio: number; pbutton_gpio: number;
trace_raw: boolean; trace_raw: boolean;
board_profile: string;
} }
export enum busConnectionStatus { export enum busConnectionStatus {
@@ -57,7 +58,54 @@ export interface EMSESPDevices {
sensors: Sensor[]; sensors: Sensor[];
} }
export interface DeviceValue {
v: any;
u: number;
n: string;
c: string;
}
export interface EMSESPDeviceData { export interface EMSESPDeviceData {
name: string; name: string;
data: string[]; data: DeviceValue[];
} }
export enum DeviceValueUOM {
NONE = 0,
DEGREES,
PERCENT,
LMIN,
KWH,
WH,
HOURS,
MINUTES,
UA,
BAR,
KW,
W,
KB,
SECONDS,
DBM,
NUM,
BOOLEAN
}
export const DeviceValueUOM_s = [
'',
'°C',
'%',
'l/min',
'kWh',
'Wh',
'hours',
'minutes',
'uA',
'bar',
'kW',
'W',
'KB',
'seconds',
'dBm',
'number',
'on/off'
];

View File

@@ -1,12 +1,15 @@
import React, { Component } from "react"; import { Component } from 'react';
import { Link, withRouter, RouteComponentProps } from "react-router-dom"; import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { List, ListItem, ListItemIcon, ListItemText } from "@material-ui/core"; import { List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
import TuneIcon from '@material-ui/icons/Tune'; import TuneIcon from '@material-ui/icons/Tune';
import DashboardIcon from "@material-ui/icons/Dashboard"; import DashboardIcon from '@material-ui/icons/Dashboard';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
type ProjectProps = AuthenticatedContextProps & RouteComponentProps; type ProjectProps = AuthenticatedContextProps & RouteComponentProps;
@@ -16,17 +19,32 @@ class ProjectMenu extends Component<ProjectProps> {
const path = this.props.match.url; const path = this.props.match.url;
return ( return (
<List> <List>
<ListItem to='/ems-esp/' selected={path.startsWith('/ems-esp/status') || path.startsWith('/ems-esp/devices') || path.startsWith('/ems-esp/help')} button component={Link}> <ListItem
to="/ems-esp/"
selected={
path.startsWith('/ems-esp/status') ||
path.startsWith('/ems-esp/devices') ||
path.startsWith('/ems-esp/help')
}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<DashboardIcon /> <DashboardIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Dashboard" /> <ListItemText primary="Dashboard" />
</ListItem> </ListItem>
<ListItem to='/ems-esp/settings' selected={path.startsWith('/ems-esp/settings')} button component={Link} disabled={!authenticatedContext.me.admin}> <ListItem
to="/ems-esp/settings"
selected={path.startsWith('/ems-esp/settings')}
button
component={Link}
disabled={!authenticatedContext.me.admin}
>
<ListItemIcon> <ListItemIcon>
<TuneIcon /> <TuneIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Settings" /> <ListItemText primary="Settings" />
</ListItem> </ListItem>
</List> </List>
); );

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