30 Commits
test ... v3.4.1

Author SHA1 Message Date
Proddy
18e9b99413 Merge remote-tracking branch 'origin/dev' into main 2022-05-29 16:16:38 +02:00
Proddy
a47e0e8266 update for 3.4.0 2022-05-23 21:20:45 +02:00
Proddy
f412ddc716 Merge remote-tracking branch 'origin/dev' into main 2022-05-23 21:20:36 +02:00
proddy
29110e96e5 Merge remote-tracking branch 'origin/dev' 2022-01-20 10:51:40 +01:00
proddy
b65866217a 3.4.0 2021-11-28 23:03:28 +01:00
proddy
611e3b1243 Merge remote-tracking branch 'origin/dev' 2021-11-28 23:03:15 +01:00
proddy
2ca0a0c634 v3.2.1 merged from dev 2021-08-08 14:46:14 +02:00
proddy
7eb1f061b7 Merge remote-tracking branch 'origin/dev' for 3.2.0 release 2021-08-06 12:06:08 +02:00
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
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
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
57a516a83a updated README and images 2021-05-09 15:13:16 +02:00
proddy
a57fdaa4b3 Merge remote-tracking branch 'origin/dev' into main 2021-05-04 12:21:51 +02:00
proddy
4841e42286 Merge remote-tracking branch 'origin/dev' into main 2021-03-30 16:35:18 +02:00
proddy
8c2d2b06ed cleaned up old changelog 2021-03-18 20:59:09 +01:00
proddy
38c8b1b7f0 3.0.0 2021-03-18 20:58:21 +01:00
proddy
6fb5933a02 Merge remote-tracking branch 'origin/dev' into main 2021-03-18 20:58:12 +01:00
proddy
c0944433be remove workspace.code-workspace 2021-03-16 17:41:42 +01:00
Proddy
478e6362c9 Merge pull request #27 from FauthD:main
Add global names to Dallas sensors to avoid ugly <unknown> and other …
2021-03-16 17:39:00 +01:00
fauthd
4d6354db78 Add stuff to gitignore, add vscode workspace 2021-03-16 16:47:09 +01:00
fauthd
beab0f0c77 Add global names to Dallas sensors to avoid ugly <unknown> and other issues in HA 2021-03-16 16:00:23 +01:00
Proddy
c17749bd22 Update README.md 2021-03-14 23:43:33 +01:00
proddy
2bad769c5c build: include assets 2021-03-14 21:20:51 +01:00
proddy
8ad89ca64b move repo 2021-03-14 21:05:15 +01:00
proddy
9244d8daec Semantic Commit Messages 2021-03-14 18:10:57 +01:00
proddy
02d01334b2 update new build 2021-03-14 17:37:18 +01:00
776 changed files with 80831 additions and 76380 deletions

View File

@@ -1,50 +1,35 @@
--- ---
name: Problem Report name: Bug report
about: Create a Report to help us improve about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
--- ---
<!-- Thanks for reporting a problem for this project. READ THIS FIRST: *Before creating a new issue please check that you have:*
Please DO NOT OPEN AN ISSUE if your EMS-ESP version is not the latest from the dev branch, please update your device before submitting your issue. Your problem might already be solved. The latest precompiled binaries of EMS-ESP can be downloaded from https://github.com/emsesp/EMS-ESP32/releases/tag/latest * *searched the existing [issues](https://github.com/emsesp/EMS-ESP32/issues) (both open and closed)*
* *searched the [documentation help section](https://emsesp.github.io/docs)*
Please take a few minutes to complete the requested information below. *Completing this template will help developers and contributors to address the issue. Try to be as specific and extensive as possible. If the information provided is not enough the issue will likely be closed.*
--> *You can now remove this line and the above ones. Text in italic is meant to be replaced by your own words. If any of the sections below are not relevant to the issue (for instance, the screenshots) then you can delete them.*
### PROBLEM DESCRIPTION **Bug description**
*A clear and concise description of what the bug is. Mention which EMS-ESP version you're using.*
_A clear and concise description of what the problem is._ **Steps to reproduce**
*Steps to reproduce the behavior.*
### REQUESTED INFORMATION **Expected behavior**
*A clear and concise description of what you expected to happen.*
_Make sure your have performed every step and checked the applicable boxes before submitting your issue. Thank you!_ **Screenshots**
*If applicable, add screenshots to help explain your problem.*
- [ ] Searched the problem in [issues](https://github.com/emsesp/EMS-ESP32/issues) **Device information**
- [ ] Searched the problem in [discussions](https://github.com/emsesp/EMS-ESP32/discussions) *Copy-paste here the information as it is outputted by the device. You can get this information by from http://ems-esp.local/api/system*
- [ ] Searched the problem in the [docs](https://emsesp.github.io/docs/Troubleshooting/)
- [ ] Searched the problem in the [chat](https://discord.gg/3J3GgnzpyT)
- [ ] Provide the output of http://ems-esp.local/api/system :
```lua **Additional context**
System information output here: *Add any other context about the problem here.*
```
### TO REPRODUCE
_Steps to reproduce the behavior:_
### EXPECTED BEHAVIOUR
_A clear and concise description of what you expected to happen._
### SCREENSHOTS
_If applicable, add screenshots to help explain your problem._
### ADDITIONAL CONTEXT
_Add any other context about the problem here._
**(Please, remember to close the issue when the problem has been addressed)**

View File

@@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: EMS-ESP Docs
url: https://emsesp.github.io/docs/
about: All the information related to EMS-ESP.
- name: EMS-ESP Discussions and Support
url: https://github.com/emsesp/EMS-ESP32/discussions
about: EMS-ESP usage Questions, Feature Requests and Projects.
- name: EMS-ESP Users Chat
url: https://discord.gg/3J3GgnzpyT
about: Chat for feedback, questions and troubleshooting.

View File

@@ -0,0 +1,26 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
*Before creating a new feature request please check that you have searched the existing [issues](https://github.com/emsesp/EMS-ESP32/issues) (both open and closed)*
*Completing this template will help developers and contributors evaluating the feature. If the information provided is not enough the issue will likely be closed.*
*You can now remove this line and the above ones. Text in italic is meant to be replaced by your own words. If any of the sections below are not relevant to the request then you can delete them.*
**Is your feature request related to a problem? Please describe.**
*A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]*
**Describe the solution you'd like**
*A clear and concise description of what you want to happen.*
**Describe alternatives you've considered**
*A clear and concise description of any alternative solutions or features you've considered.*
**Additional context**
*Add any other context or screenshots about the feature request here.*

View File

@@ -0,0 +1,29 @@
---
name: Questions & Troubleshooting
about: Anything not a bug or feature request
title: ''
labels: question
assignees: ''
---
*Before creating a new issue please check that you have:*
* *searched the existing [issues](https://github.com/emsesp/EMS-ESP32/issues) (both open and closed)*
* *searched the [documentation help section](https://emsesp.github.io/docs)*
*Completing this template will help developers and contributors help you. Try to be as specific and extensive as possible. If the information provided is not enough the issue will likely be closed.*
*You can now remove this line and the above ones. Text in italic is meant to be replaced by your own words. If any of the sections below are not relevant to the issue (for instance, the screenshots) then you can delete them.*
**Question**
*A clear and concise description of what the problem/doubt is.*
**Screenshots**
*If applicable, add screenshots to help explain your problem.*
**Device information**
*Copy-paste here the information as it is outputted by the device. You can get this information from http://ems-esp.local/api/system*
**Additional context**
*Add any other context about the problem here.*

View File

@@ -1,23 +0,0 @@
name: 'github-releases-to-discord'
on:
release:
types: [published]
jobs:
github-releases-to-discord:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Github Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.13.1
with:
webhook_url: ${{ secrets.WEBHOOK_URL }}
color: '2105893'
username: 'Release Changelog'
avatar_url: 'https://cdn.discordapp.com/icons/816637840644505620/0b14718532d855c452903851b4f0c9a2.png'
content: '||@everyone||'
footer_title: 'Changelog'
footer_icon_url: 'https://cdn.discordapp.com/icons/816637840644505620/0b14718532d855c452903851b4f0c9a2.png'
footer_timestamp: true

View File

@@ -1,65 +1,57 @@
name: 'pre-release' name: "pre-release"
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- 'dev' - "dev"
jobs: jobs:
pre-release: pre-release:
name: 'Automatic pre-release build'
name: "Automatic pre-release build"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/setup-node@v2
with: with:
python-version: '3.11' node-version: '16'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get EMS-ESP source code and version - name: Get EMS-ESP source code and version
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 "VERSION=$version" >> $GITHUB_OUTPUT echo "::set-output name=version::$version"
- name: Install PlatformIO - name: Install PlatformIO
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 update
- name: Build WebUI - name: Build WebUI
run: | run: |
cd interface cd interface
yarn install npm ci
yarn typesafe-i18n --no-watch npm run build
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build
yarn webUI
- name: Build firmware - name: Build firmware
run: | run: |
platformio run -e ci platformio run -e ci
- name: Build S3 firmware
run: |
platformio run -e ci_s3
- name: Build E32V2 firmware
run: |
platformio run -e ci_16M
- name: Create a GH 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: Development Build v${{steps.build_info.outputs.VERSION}} title: 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

@@ -7,24 +7,51 @@ on:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
jobs: jobs:
build: build:
name: Build and analyze name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
# if: github.repository_owner == 'emsesp' if: github.repository_owner == 'emsesp'
# if: github.repository == 'emsesp/EMS-ESP32' # if: github.repository == 'emsesp/EMS-ESP32'
env: env:
BUILD_WRAPPER_OUT_DIR: bw-output # https://binaries.sonarsource.com/?prefix=Distribution/sonar-scanner-cli/
SONAR_SCANNER_VERSION: 4.7.0.2747
SONAR_SERVER_URL: "https://sonarcloud.io"
BUILD_WRAPPER_OUT_DIR: build_wrapper_output_directory
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Install sonar-scanner and build-wrapper - name: Set up JDK 11
uses: SonarSource/sonarcloud-github-c-cpp@v2 uses: actions/setup-java@v1
with:
java-version: 11
- name: Cache SonarCloud packages
uses: actions/cache@v1
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Download and set up sonar-scanner
env:
SONAR_SCANNER_DOWNLOAD_URL: https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux.zip
run: |
mkdir -p $HOME/.sonar
curl -sSLo $HOME/.sonar/sonar-scanner.zip ${{ env.SONAR_SCANNER_DOWNLOAD_URL }}
unzip -o $HOME/.sonar/sonar-scanner.zip -d $HOME/.sonar/
echo "$HOME/.sonar/sonar-scanner-${{ env.SONAR_SCANNER_VERSION }}-linux/bin" >> $GITHUB_PATH
- name: Download and set up build-wrapper
env:
BUILD_WRAPPER_DOWNLOAD_URL: ${{ env.SONAR_SERVER_URL }}/static/cpp/build-wrapper-linux-x86.zip
run: |
curl -sSLo $HOME/.sonar/build-wrapper-linux-x86.zip ${{ env.BUILD_WRAPPER_DOWNLOAD_URL }}
unzip -o $HOME/.sonar/build-wrapper-linux-x86.zip -d $HOME/.sonar/
echo "$HOME/.sonar/build-wrapper-linux-x86" >> $GITHUB_PATH
- name: Run build-wrapper - name: Run build-wrapper
run: | run: |
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all make clean
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make clean all
- name: Run sonar-scanner - name: Run sonar-scanner
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: | run: |
sonar-scanner --define sonar.cfamily.build-wrapper-output="${{ env.BUILD_WRAPPER_OUT_DIR }}" sonar-scanner

View File

@@ -1,52 +1,45 @@
name: 'tagged-release' name: "tagged-release"
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
jobs: jobs:
tagged-release: tagged-release:
name: 'Tagged Release'
name: "Tagged Release"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/setup-node@v2
with: with:
python-version: '3.11' node-version: '16'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PlatformIO - name: Install PlatformIO
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
pio pkg update platformio update
- name: Build WebUI - name: Build WebUI
run: | run: |
cd interface cd interface
yarn install npm ci
yarn typesafe-i18n --no-watch npm run build
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build
yarn webUI
- name: Build firmware - name: Build firmware
run: | run: |
platformio run -e ci platformio run -e ci
- name: Build S3 firmware
run: |
platformio run -e ci_s3
- name: Release - name: Release
uses: 'marvinpinto/action-automatic-releases@latest' uses: "marvinpinto/action-automatic-releases@latest"
with: with:
repo_token: '${{ secrets.GITHUB_TOKEN }}' repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false prerelease: false
files: | files: |
CHANGELOG.md CHANGELOG.md

View File

@@ -1,61 +0,0 @@
name: 'test-release'
on:
workflow_dispatch:
push:
branches:
- 'dev2'
jobs:
pre-release:
name: 'Automatic test-release build'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get EMS-ESP source code and version
id: build_info
run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT
- name: Install PlatformIO
run: |
python -m pip install --upgrade pip
pip install -U platformio
- name: Build WebUI
run: |
cd interface
yarn install
yarn typesafe-i18n --no-watch
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build
yarn webUI
- name: Build firmware
run: |
platformio run -e ci
- name: Build S3 firmware
run: |
platformio run -e ci_s3
- name: Create a GH Release
id: 'automatic_releases'
uses: 'marvinpinto/action-automatic-releases@latest'
with:
repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Test Build v${{steps.build_info.outputs.VERSION}}
automatic_release_tag: 'test'
prerelease: true
files: |
CHANGELOG_LATEST.md
./build/firmware/*.*

50
.gitignore vendored
View File

@@ -1,63 +1,37 @@
# vscode # vscode
.vscode/c_cpp_properties.json .vscode
.vscode/extensions.json .directory
.vscode/launch.json workspace.code-workspace
#.vscode/settings.json
# c++ compiling # build
build/
.clang_complete .clang_complete
.gcc-flags.json .gcc-flags.json
cppcheck.out.xml cppcheck.out.xml
debug.log
# platformio # platformio
.pio .pio
pio_local.ini pio_local.ini
*_old /.VSCodeCounter
# OS specific # OS specific
.DS_Store .DS_Store
*Thumbs.db *Thumbs.db
# web specfic # project specfic
build/ /scripts/stackdmp.txt
dist/ emsesp
/data/www /data/www
/lib/framework/WWWData.h /lib/framework/WWWData.h
/interface/build /interface/build
node_modules node_modules
/interface/.eslintcache /interface/.eslintcache
stats.html
*.sln
*.sw?
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
yarn.lock
analyse.html
interface/vite.config.ts.timestamp*
# scripts
test.sh test.sh
scripts/run.sh
scripts/__pycache__ scripts/__pycache__
scripts/stackdmp.txt .temp
# i18n generated files
interface/src/i18n/i18n-react.tsx
interface/src/i18n/i18n-types.ts
interface/src/i18n/i18n-util.ts
interface/src/i18n/i18n-util.sync.ts
interface/src/i18n/i18n-util.async.ts
# sonar # sonar
.scannerwork/ .scannerwork/
sonar/ sonar/
bw-output/ build_wrapper_output_directory/
# testing
emsesp

View File

@@ -1,10 +0,0 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

91
.vscode/settings.json vendored
View File

@@ -1,91 +0,0 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.nodePath": "interface/.yarn/sdks",
"eslint.workingDirectories": ["interface"],
"prettier.prettierPath": "",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": {
"*.tsx": "typescriptreact",
"*.tcc": "cpp",
"optional": "cpp",
"istream": "cpp",
"ostream": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"array": "cpp",
"functional": "cpp",
"regex": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"string": "cpp",
"string_view": "cpp",
"atomic": "cpp",
"bitset": "cpp",
"cctype": "cpp",
"chrono": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"cstring": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"list": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"exception": "cpp",
"algorithm": "cpp",
"iterator": "cpp",
"map": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"numeric": "cpp",
"random": "cpp",
"set": "cpp",
"fstream": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"streambuf": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp"
},
"todo-tree.filtering.excludeGlobs": [
"**/vendor/**",
"**/node_modules/**",
"**/dist/**",
"**/bower_components/**",
"**/build/**",
"**/.vscode/**",
"**/.github/**",
"**/_output/**",
"**/*.min.*",
"**/*.map",
"**/ArduinoJson/**"
],
"cSpell.enableFiletypes": [
"!cpp",
"!typescript"
]
}

View File

@@ -5,303 +5,15 @@ 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.6.5] March 23 2024 # Changelog
## **IMPORTANT! BREAKING CHANGES** ## [3.4.1] May 29 2022
- The Wifi Tx Power setting in Network Settings will be reset to Auto ### Fixed
## Added
- thermostat boost mode and boost time [#1446](https://github.com/emsesp/EMS-ESP32/issues/1446)
- heatpump energy meters [#1463](https://github.com/emsesp/EMS-ESP32/issues/1463)
- heatpump max power [#1475](https://github.com/emsesp/EMS-ESP32/issues/1475)
- checkbox for MQTT-TLS enable [#1474](https://github.com/emsesp/EMS-ESP32/issues/1474)
- added SK (Slovak) language. Thanks @misa1515
- CPU info [#1497](https://github.com/emsesp/EMS-ESP32/pull/1497)
- Show network hostname in Web UI under Network Status
- Improved HA Discovery so each section (EMS device, Scheduler, Analog, Temperature, Custom, Shower) have their own section
- boiler Bosch C1200W, id 12, [#1536](https://github.com/emsesp/EMS-ESP32/issues/1536)
- mixer MM100 telegram 0x2CC [#1554](https://github.com/emsesp/EMS-ESP32/issues/1554)
- boiler hpSetDiffPressure [#1563](https://github.com/emsesp/EMS-ESP32/issues/1563)
- custom variables [#1423](https://github.com/emsesp/EMS-ESP32/issues/1423)
- weather compensation [#1642](https://github.com/emsesp/EMS-ESP32/issues/1642)
- env and partitions for DevKitC-1-N32R8 [#1635](https://github.com/emsesp/EMS-ESP32/discussions/1635)
- command `restart partitionname` and button long press to start with other partition [#1657](https://github.com/emsesp/EMS-ESP32/issues/1657)
- command `set service <mqtt|ota|ntp|ap> <enable|disable>` [#1663](https://github.com/emsesp/EMS-ESP32/issues/1663)
## Fixed
- exhaust temperature for some boilers
- add back boil2hyst [#1477](https://github.com/emsesp/EMS-ESP32/issues/1477)
- subscribed MQTT topics not detecting changes by EMS-ESP [#1494](https://github.com/emsesp/EMS-ESP32/issues/1494)
- changed HA name and grouping to be consistent [#1528](https://github.com/emsesp/EMS-ESP32/issues/1528)
- MQTT autodiscovery in Domoticz not working [#1360](https://github.com/emsesp/EMS-ESP32/issues/1528)
- dhw comfort for new ems+, [#1495](https://github.com/emsesp/EMS-ESP32/issues/1495)
- added writeable icon to Web's Custom Entity page for each entity shown in the table
- Wifi Tx Power not adjusted [#1614](https://github.com/emsesp/EMS-ESP32/issues/1614)
- MQTT discovery of custom entity doesn't consider type of data [#1587](https://github.com/emsesp/EMS-ESP32/issues/1587)
- WiFi TxPower wasn't correctly used. Added an 'Auto' setting, which is the default.
- dns w/wo IPv6 [#1644](https://github.com/emsesp/EMS-ESP32/issues/1644)
## Changed
- HA don't set entity_category to Diagnostic/Configuration for EMS entities [#1459](https://github.com/emsesp/EMS-ESP32/discussions/1459)
- upgraded ArduinoJson to 7.0.0 #1538 and then 7.0.2
- small changes to the API for analog and temperature sensors
- Length of mqtt Broker adress [#1619](https://github.com/emsesp/EMS-ESP32/issues/1619)
- C++ optimizations - see <https://github.com/emsesp/EMS-ESP32/pull/1615>
- Send MQTT heartbeat immediately after connection [#1628](https://github.com/emsesp/EMS-ESP32/issues/1628)
- 16MB partitions with second nvs, larger FS, Coredump, optional factory partition
- stop fetching empty telegrams after 5 min
## [3.6.4] November 24 2023
## **IMPORTANT! BREAKING CHANGES**
Writeable Text entities have moved from type `sensor` to `text` in Home Assistant to make them also editable within an HA dashboard. Examples are `datetime`, `holidays`, `switchtime`, `vacations`, `maintenancedate`... You will need to manually remove any old discovery topics from your MQTT broker using an application like MQTT Explorer.
## Added
- humidity for ventilation devices
- telegrams for RC100H, hc2, etc. (seen on discord, not tested)
- names for BC400, GB192i.2, read temperatures for low loss header and heatblock [#1317](https://github.com/emsesp/EMS-ESP32/discussions/1317)
- option for `forceheatingoff` [#1262](https://github.com/emsesp/EMS-ESP32/issues/1262)
- remote thermostat emulation RC100H for RC3xx [#1278](https://github.com/emsesp/EMS-ESP32/discussions/1278)
- shower_data MQTT payload contains the timestamp [#1329](https://github.com/emsesp/EMS-ESP32/issues/1329)
- HA discovery for writeable text entities [#1337](https://github.com/emsesp/EMS-ESP32/pull/1377)
- autodetect board_profile, store in nvs, add telnet command option, add E32V2
- heat pump high res energy counters [#1348, #1349. #1350](https://github.com/emsesp/EMS-ESP32/issues/1348)
- optional bssid in network settings
- extension module EM100 [#1315](https://github.com/emsesp/EMS-ESP32/discussions/1315)
- digital_out with new options for polarity and startup state
- added 'system allvalues' command that dumps all the EMS device values, plus sensors and any custom entities
## Fixed
- remove command `remoteseltemp`, thermostat accept it only from remote thermostat
- shower_data MQTT payload contains the timestamp [#1329](https://github.com/emsesp/EMS-ESP32/issues/1329)
- fixed helper text in Web Device Entity dialog box for numerical ranges
- MQTT base with paths not working in HA [#1393](https://github.com/emsesp/EMS-ESP32/issues/1393)
- set/read thermostat mode for RC100-RC300, [#1440](https://github.com/emsesp/EMS-ESP32/issues/1440) [#1442](https://github.com/emsesp/EMS-ESP32/issues/1442)
- some setting commands for ems-boiler have used wrong ems+ telegram in 3.6.3
## Changed
- update to platform 6.4.0, arduino 2.0.14 / idf 4.4.6
- small changes for arduino 3.0.0 / idf 5.1 compatibility (not backward compatible to platform 6.3.2 and before)
- AP start after 10 sec, stay until station/eth connected
- tested wifi-all-channel-scan (3.6.3-dev4 a-e), removed again because of connect issues
- mqtt disconnect stops queue
## [3.6.2] October 1 2023
## **IMPORTANT! BREAKING CHANGES**
## Added
- Power entities
- Optional input of BSSID for AP connection
- Return empty json if no entries in scheduler/custom/analogsensor/temperaturesensor
## Fixed
- Wifi full scan to get strongest AP
- Add missing dhw tags
- Sending a dash/- to the Reset command doesn't return an error [#1308](https://github.com/emsesp/EMS-ESP32/discussions/1308)
## Changed
- MQTT queue max 300 messages, check heap and maxAlloc
- API call commands are logged as WARN in the log
- Reset Command renamed to 'reset' in lowercase in EN
## [3.6.1] September 9 2023
## **IMPORTANT! BREAKING CHANGES**
- `shower_data` MQTT topic shows duration is seconds (was previously a full english sentence)
## Added
- Show WiFi rssi in Network Status Page, show quality as color
## Fixed
- Issue in espMqttClient causing a memory leak when MQTT broker is disconnected due to network unavailability [#1264](https://github.com/emsesp/EMS-ESP32/issues/1264)
- Using MQTT enum values correctly formatted in MQTT Discovery [#1280](https://github.com/emsesp/EMS-ESP32/issues/1280)
## Changed
- MQTT free mem check set to 60 kb
- Small cosmetic changes to Searching in Customization web page
- Updated to espressif32@6.4.0
# [3.6.0] August 13 2023
## **IMPORTANT! BREAKING CHANGES**
There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please read carefully before applying the update.
- The sensors have been renamed. `dallassensor` is now `temperaturesensor` in the MQTT topic and named `ts` in the Customizations file. Likewise `analogs` is now `analogsensor` in MQTT and called `as` in the Customizations file. If you have previous customizations you will need to manually update by downloading, changing the JSON file and uploading. It's also recommended cleaning up any old MQTT topics from your broker using an application like MQTTExplorer.
## Added
- Workaround for better Domoticz MQTT integration? [#904](https://github.com/emsesp/EMS-ESP32/issues/904)
- Show MAC address without connecting to network enhancement [#933](https://github.com/emsesp/EMS-ESP32/issues/933)
- Warn user in WebUI of unsaved changes [#911](https://github.com/emsesp/EMS-ESP32/issues/911)
- Detect old Tado thermostat, device-id 0x19, no entities
- Some more HM200 entities [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
- Added Scheduler [#701](https://github.com/emsesp/EMS-ESP32/issues/701)
- Added Custom Entities read/write from EMS bus
- Build S3 binary with github actions
- Greenstar HIU [#1158](https://github.com/emsesp/EMS-ESP32/issues/1158)
- AM200 code 10 [#1161](https://github.com/emsesp/EMS-ESP32/issues/1161)
- Ventilation device (Logavent HRV176) [#1172](https://github.com/emsesp/EMS-ESP32/issues/1172)
- Turn ETH off on wifi connect [#1167](https://github.com/emsesp/EMS-ESP32/issues/1167)
- Support for multiple EMS-ESPs with HA [#1196](https://github.com/emsesp/EMS-ESP32/issues/1196)
- Italian translation [#1199](https://github.com/emsesp/EMS-ESP32/issues/1199)
- Turkish language support [#1076](https://github.com/emsesp/EMS-ESP32/issues/1076)
- Buderus GB182 - HC1 mode change not work bug [#1193](https://github.com/emsesp/EMS-ESP32/issues/1193)
- Minimal flow temperature enhancement [#1192](https://github.com/emsesp/EMS-ESP32/issues/1192)
- Roomtemperature Switching Difference enhancement [#1191](https://github.com/emsesp/EMS-ESP32/issues/1191)
- Dew Point Temperature Difference enhancement [#1190](https://github.com/emsesp/EMS-ESP32/issues/1190)
- Control of heating circuit mode enhancement [#1187](https://github.com/emsesp/EMS-ESP32/issues/1187)
- Warn user in WebUI of unsaved changes enhancement [#911](https://github.com/emsesp/EMS-ESP32/issues/911)
- Create safebuild app to fit into factory partition to give ESP32 more flash memory enhancement [#608](https://github.com/emsesp/EMS-ESP32/issues/608)
- Support ESP32 S2, C3 mini and S3 [#605](https://github.com/emsesp/EMS-ESP32/issues/605)
- Support Buderus AM200 [#1161](https://github.com/emsesp/EMS-ESP32/issues/1161)
- Custom telegram handler [#1155](https://github.com/emsesp/EMS-ESP32/issues/1155)
- Added support for TLS in MQTT (ESP32-S3 only) [#1178](https://github.com/emsesp/EMS-ESP32/issues/1178)
- Boardprofile BBQKees Gateway S3
- Custom entity type RAW [#1212](https://github.com/emsesp/EMS-ESP32/discussions/1212)
- API command response [#1212](https://github.com/emsesp/EMS-ESP32/discussions/1212)
## Fixed
- HA-discovery for analog sensor commands [#1035](https://github.com/emsesp/EMS-ESP32/issues/1035)
- Enum order of RC3x nofrost mode
- Heartbeat interval
- Exhaust temperature always zero on GB125/MC110/RC310 bug [#1147](https://github.com/emsesp/EMS-ESP32/issues/1147)
- thermostat modetype is not changing when mode changes (e.g. to night) bugSomething isn't working [#1098](https://github.com/emsesp/EMS-ESP32/issues/1098)
- NTP: cant apply changed timezone [#1182](https://github.com/emsesp/EMS-ESP32/issues/1182)
- Missing Status of VS1 for Buderus SM200 enhancement [#1034](https://github.com/emsesp/EMS-ESP32/issues/1034)
- Allowed gpios for S3
## Changed
- Optional upgrade to platform-espressif32 6.3.0 (after 5.3.0) [#862](https://github.com/emsesp/EMS-ESP32/issues/862)
- Use byte 3 for detection RC30 active heatingcircuit [#786](https://github.com/emsesp/EMS-ESP32/issues/786)
- Write repeated selflowtemp if tx-queue is empty without verify [#954](https://github.com/emsesp/EMS-ESP32/issues/954)
- HA discovery recreate after disconnect by device [#1067](https://github.com/emsesp/EMS-ESP32/issues/1067)
- File upload: check flash size (overflow) instead of filesize
- Improved HA Discovery so previous configs no longer need to be removed when starting [#1077](https://github.com/emsesp/EMS-ESP32/pull/1077) (thanks @pswid)
- Enlarge UART-Stack to 2,5k
- Retry timeout for Mqtt-QOS1/2 10seconds
- Optimize WebUI rendering when using Dialog Boxes [#1116](https://github.com/emsesp/EMS-ESP32/issues/1116)
- Optimize Web libraries to reduce bundle size (3.6.x) [#1112](https://github.com/emsesp/EMS-ESP32/issues/1112)
- Use [espMqttClient](https://github.com/bertmelis/espMqttClient) with integrated queue [#1178](https://github.com/emsesp/EMS-ESP32/issues/1178)
- Move Sensors from Web dashboard to it's own tab enhancement [#1170](https://github.com/emsesp/EMS-ESP32/issues/1170)
- Optimize WebUI dashboard data [#1169](https://github.com/emsesp/EMS-ESP32/issues/1169)
- Replace React core library with Preact to save on memory footprint
- Response to `system/send` raw reads gives combined data for telegrams with more parts
# [3.5.0] February 6 2023
## **IMPORTANT! BREAKING CHANGES**
- When upgrading to v3.5 for the first time from v3.4 on a BBQKees Gateway board you will need to use the [EMS-EPS Flasher](https://github.com/emsesp/EMS-ESP-Flasher/releases) to correctly re-partition the flash. Make sure you backup the settings and customizations from the WebUI (System->Upload/Download) and restore after the upgrade.
## Added
- Translations in Web UI and all device entity names (DE, NL, SV, PL, NO, FR) [#22](https://github.com/emsesp/EMS-ESP32/issues/22)
- Add support for Lolin C3 mini [#620](https://github.com/emsesp/EMS-ESP32/pull/620)
- Add support for ESP32-S2 [#667](https://github.com/emsesp/EMS-ESP32/pull/667)
- Add devices: Greenstar 30Ri boiler, Junkers FW500 thermostat, Buderus BC30 controller
- Add program memory info
- Add mqtt queue and connection infos
- Adapt min/max if ems-value is not in this range
- Add heat pump settings for inputs and limits [#600](https://github.com/emsesp/EMS-ESP32/issues/600)
- Add hybrid heatpump [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
- Add translated tags
- Add min/max to customization table [#686](https://github.com/emsesp/EMS-ESP32/issues/686)
- Add MD5 check [#637](https://github.com/emsesp/EMS-ESP32/issues/637)
- Add more bus-ids [#673](https://github.com/emsesp/EMS-ESP32/issues/673)
- Use HA connectivity device class for Status, added boot time [#751](https://github.com/emsesp/EMS-ESP32/issues/751)
- Add commands for analog sensors outputs
- Support for multiple EMS-ESPs with MQTT and HA [[#759](https://github.com/emsesp/EMS-ESP32/issues/759)]
- Settings for heatpump silent mode and additional heater [[#802](https://github.com/emsesp/EMS-ESP32/issues/802)] [[#803](https://github.com/emsesp/EMS-ESP32/issues/803)]
- Zone module MZ100 [#826](https://github.com/emsesp/EMS-ESP32/issues/826)
- Default MQTT hostname is blank [#829](https://github.com/emsesp/EMS-ESP32/issues/829)
- wwCurFlow for ems+ devices [#829](https://github.com/emsesp/EMS-ESP32/issues/829)
- Add Rego 3000, TR120RF thermostats [#917](https://github.com/emsesp/EMS-ESP32/issues/917)
- Add config for ESP32-S3
- Add heatpump silent mode and other entities [#896](https://github.com/emsesp/EMS-ESP32/issues/896)
- Allow reboot to other partition (factory or asymetric OTA)
- Blacklist entities to remove from memory [#891](https://github.com/emsesp/EMS-ESP32/issues/891)
- Add boiler pump operating mode [#944](https://github.com/emsesp/EMS-ESP32/issues/944)
## Fixed
- Factory Reset not working [#628](https://github.com/emsesp/EMS-ESP32/issues/628)
- Valid 4 byte values [#820](https://github.com/emsesp/EMS-ESP32/issues/820)
- Commands for multiple thermostats [#826](https://github.com/emsesp/EMS-ESP32/issues/826)
- API queries for multiple devices [#865](https://github.com/emsesp/EMS-ESP32/issues/865)
- Console crash when using call with command `hcx` only. [#841](https://github.com/emsesp/EMS-ESP32/issues/841)
- `heatingPump2Mod` was wrong, changed to absBurnPow [[#908](https://github.com/emsesp/EMS-ESP32/issues/908)
- Rounding of web input values
- Analog sensor with single gpio number [#915](https://github.com/emsesp/EMS-ESP32/issues/915)
- HA dallas and analog configs: remove/rebuild on change [#888](https://github.com/emsesp/EMS-ESP32/issues/888)
- Modes and set seltemp for RC30 and RC20 [#932](https://github.com/emsesp/EMS-ESP32/issues/932)
## Changed
- Discovery in HomeAssistant don't work with custom base topic. [#596](https://github.com/emsesp/EMS-ESP32/issues/596) Base topic containing `/` are changed to `_`
- RF room temperature sensor are shown as thermostat
- Render mqtt float json values with trailing zero
- Removed flash strings, to increase available heap memory
- Reload page after restart button is pressed
- Analog/dallas values command as list like ems-devices
- Analog/dallas HA-entities based on id
- MQTT Base is a mandatory field. Removed MQTT topic length from settings
- HA duration class for time entities [[#822](https://github.com/emsesp/EMS-ESP32/issues/822)
- AM200 alternative heatsource as class heatsource [[#857](https://github.com/emsesp/EMS-ESP32/issues/857)
# [3.4.2] September 18 2022
## Added
- RC310 additions [#520](https://github.com/emsesp/EMS-ESP32/pull/520)
- damping
- wwprio for RC310 heating circuits
- switchonoptimization for RC310 heating circuits
- enum_controlmode for RC310 (new enum list)
- nofrostmode, reducemode, reducetemp & noreducetemp for RC310
- emergencyops and emergencytemp, wwmaxtemp, wwflowtempoffset and wwcomfort1 for RC310
- HM200 hybrid module [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
- AM200 alternative heatsource module [#573](https://github.com/emsesp/EMS-ESP32/issues/573)
- EM10 error module as gateway [#575](https://github.com/emsesp/EMS-ESP32/issues/575)
## Fixed
- fix Table resizing in WebUI [#519](https://github.com/emsesp/EMS-ESP32/issues/519)
- allow larger customization files [#570](https://github.com/emsesp/EMS-ESP32/issues/570)
- losing entity wwcomfort [#581](https://github.com/emsesp/EMS-ESP32/issues/581)
## Changed
- Shorten "friendly names" in Home Assistant [#555](https://github.com/emsesp/EMS-ESP32/issues/555)
- platformio 2.3.0 (IDF 4, Arduino 2)
- remove master-thermostat, support multiple thermostats
- merge up- and download in webui [#577](https://github.com/emsesp/EMS-ESP32/issues/577)
# [3.4.1] May 29 2022
## Fixed
- Fix memory leak in api [#524](https://github.com/emsesp/EMS-ESP32/issues/524) - Fix memory leak in api [#524](https://github.com/emsesp/EMS-ESP32/issues/524)
## Changed ### Changed
- Controller data in web-ui only for IVT [#522](https://github.com/emsesp/EMS-ESP32/issues/522) - Controller data in web-ui only for IVT [#522](https://github.com/emsesp/EMS-ESP32/issues/522)
- Rename hidden `climate` to a more explaining name [#523](https://github.com/emsesp/EMS-ESP32/issues/523) - Rename hidden `climate` to a more explaining name [#523](https://github.com/emsesp/EMS-ESP32/issues/523)
@@ -313,7 +25,7 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- WebUI optimizations, updated look&feel and better performance [#124](https://github.com/emsesp/EMS-ESP32/issues/124) - WebUI optimizations, updated look&feel and better performance [#124](https://github.com/emsesp/EMS-ESP32/issues/124)
- Auto refresh of WebUI after successful firmware upload [#178](https://github.com/emsesp/EMS-ESP32/issues/178) - Auto refresh of WebUI after successful firmware upload [#178](https://github.com/emsesp/EMS-ESP32/issues/178)
- New Customization Service in WebUI. First feature is the ability to enable/disabled Entities (device values) from EMS devices [#206](https://github.com/emsesp/EMS-ESP32/issues/206) - New Customization Service in WebUI. First feature is the ability to enable/disabled Enitites (device values) from EMS devices [#206](https://github.com/emsesp/EMS-ESP32/issues/206)
- Option to disable Telnet Console [#209](https://github.com/emsesp/EMS-ESP32/issues/209) - Option to disable Telnet Console [#209](https://github.com/emsesp/EMS-ESP32/issues/209)
- Added Hide SSID, Max Clients and Preferred Channel to Access Point - Added Hide SSID, Max Clients and Preferred Channel to Access Point
- Merged in MichaelDvP's changes like Fahrenheit conversion, publish single (for IOBroker) and a few other critical optimizations - Merged in MichaelDvP's changes like Fahrenheit conversion, publish single (for IOBroker) and a few other critical optimizations
@@ -413,7 +125,7 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- Added pool data to telegrams 0x494 & 0x495 [#102](https://github.com/emsesp/EMS-ESP32/issues/102) - Added pool data to telegrams 0x494 & 0x495 [#102](https://github.com/emsesp/EMS-ESP32/issues/102)
- Add RC300 second summermode telegram [#108](https://github.com/emsesp/EMS-ESP32/issues/108) - Add RC300 second summermode telegram [#108](https://github.com/emsesp/EMS-ESP32/issues/108)
- Add support for the RC25 thermostat [#106](https://github.com/emsesp/EMS-ESP32/issues/106) - Add support for the RC25 thermostat [#106](https://github.com/emsesp/EMS-ESP32/issues/106)
- Add new command 'entities' for a device, e.g. <http://ems-esp/api/boiler/entities> to show the shortname, description and HA Entity name (if HA enabled) [#116](https://github.com/emsesp/EMS-ESP32/issues/116) - Add new command 'entities' for a device, e.g. http://ems-esp/api/boiler/entities to show the shortname, description and HA Entity name (if HA enabled) [#116](https://github.com/emsesp/EMS-ESP32/issues/116)
- Support for Junkers program and remote (fb10/fb110) temperature - Support for Junkers program and remote (fb10/fb110) temperature
- Home Assistant `state_class` attribute for Wh, kWh, W and KW [#129](https://github.com/emsesp/EMS-ESP32/issues/129) - Home Assistant `state_class` attribute for Wh, kWh, W and KW [#129](https://github.com/emsesp/EMS-ESP32/issues/129)
- Add current room influence for RC300 [#136](https://github.com/emsesp/EMS-ESP32/issues/136) - Add current room influence for RC300 [#136](https://github.com/emsesp/EMS-ESP32/issues/136)
@@ -572,15 +284,51 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
## Added ## 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) - individual mqtt commands (#31)
- board Profiles (#11) - board Profiles (#11)
## Fixed ## 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 - Rx and Tx quality % would sometimes show > 100
## Changed ## 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. - invert LED changed to Hide LED. Default is off.
- renamed Scan Network to Scan WiFi Network - renamed Scan Network to Scan WiFi Network
- added version to cmd=settings - added version to cmd=settings
@@ -635,4 +383,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- some names of mqtt-tags like in v2.2.1 - some names of mqtt-tags like in v2.2.1
- 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,23 +0,0 @@
# Changelog
## [3.7.0]
## **IMPORTANT! BREAKING CHANGES**
- new device WATER shows dhw entities from MM100 and SM100 in dhw setting
## Added
- some more entities for dhw with SM100 module
- thermostat second dhw circuit [#1634](https://github.com/emsesp/EMS-ESP32/issues/1634)
- remote thermostat emulation for RC100H, RC200 and FB10 [#1287](https://github.com/emsesp/EMS-ESP32/discussions/1287), [#1602](https://github.com/emsesp/EMS-ESP32/discussions/1602), [#1551](https://github.com/emsesp/EMS-ESP32/discussions/1551)
- heatpump dhw stop temperatures [#1624](https://github.com/emsesp/EMS-ESP32/issues/1624)
## Fixed
## Changed
- use flag for BC400 compatible thermostats, manage different mode settings
- use factory partition for 16M flash
- store digital out states to nvs
- Refresh UI - moving settings to one location [#1665](https://github.com/emsesp/EMS-ESP32/issues/1665)

View File

@@ -1,7 +1,7 @@
# #
# GNUMakefile for EMS-ESP # GNUMakefile for EMS-ESP
# (c) 2020 Paul Derbyshire
# #
NUMJOBS=${NUMJOBS:-" -j4 "} NUMJOBS=${NUMJOBS:-" -j4 "}
MAKEFLAGS+="j " MAKEFLAGS+="j "
#---------------------------------------------------------------------- #----------------------------------------------------------------------
@@ -17,32 +17,23 @@ MAKEFLAGS+="j "
#TARGET := $(notdir $(CURDIR)) #TARGET := $(notdir $(CURDIR))
TARGET := emsesp TARGET := emsesp
BUILD := build BUILD := build
SOURCES := src src/* lib_standalone lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src src/devices lib/ArduinoJson/src lib/PButton lib/semver lib/espMqttClient/src lib/espMqttClient/src/* SOURCES := src src/* lib_standalone lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src src/devices lib/ArduinoJson/src lib/PButton
INCLUDES := src lib_standalone lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src lib/semver lib/* src/devices INCLUDES := src lib_standalone lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src lib/* src/devices
LIBRARIES := LIBRARIES :=
CPPCHECK = cppcheck CPPCHECK = cppcheck
# CHECKFLAGS = -q --force --std=c++17
CHECKFLAGS = -q --force --std=c++11 CHECKFLAGS = -q --force --std=c++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Languages Standard # Languages Standard
#---------------------------------------------------------------------- #----------------------------------------------------------------------
C_STANDARD := -std=c17 C_STANDARD := -std=c11
# CXX_STANDARD := -std=c++17 CXX_STANDARD := -std=c++11
CXX_STANDARD := -std=gnu++11
# C_STANDARD := -std=c11
# CXX_STANDARD := -std=c++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Defined Symbols # Defined Symbols
#---------------------------------------------------------------------- #----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 DEFINES += -DFACTORY_WIFI_HOSTNAME=\"ems-esp\" -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_USE_SERIAL -DEMSESP_DEFAULT_BOARD_PROFILE=\"LOLIN\"
DEFINES += -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_TEST -D__linux__ -DEMC_RX_BUFFER_SIZE=1500
DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.0-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Sources & Files # Sources & Files
@@ -75,13 +66,15 @@ CXX := /usr/bin/g++
# CXXFLAGS C++ Compiler Flags # CXXFLAGS C++ Compiler Flags
# LDFLAGS Linker Flags # LDFLAGS Linker Flags
#---------------------------------------------------------------------- #----------------------------------------------------------------------
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE) CPPFLAGS += $(DEFINES) $(INCLUDE)
CPPFLAGS += -ggdb CPPFLAGS += -ggdb
CPPFLAGS += -g3 CPPFLAGS += -g3
CPPFLAGS += -Os CPPFLAGS += -Os
CFLAGS += $(CPPFLAGS) CFLAGS += $(CPPFLAGS)
CFLAGS += -Wall -Wextra -Werror -Wswitch-enum -Wno-unused-parameter -Wno-inconsistent-missing-override -Wno-missing-braces -Wno-unused-lambda-capture -Wno-sign-compare CFLAGS += -Wall
CFLAGS += -Wextra
CFLAGS += -Wno-unused-parameter
CXXFLAGS += $(CFLAGS) -MMD CXXFLAGS += $(CFLAGS) -MMD
@@ -121,8 +114,6 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
# Targets # Targets
#---------------------------------------------------------------------- #----------------------------------------------------------------------
.PHONY: all .PHONY: all
.SILENT: $(OUTPUT)
all: $(OUTPUT) all: $(OUTPUT)
$(OUTPUT): $(OBJS) $(OUTPUT): $(OBJS)
@@ -150,7 +141,7 @@ run: $(OUTPUT)
.PHONY: clean .PHONY: clean
clean: clean:
@$(RM) -rf $(BUILD) $(OUTPUT) @$(RM) -r $(BUILD) $(OUTPUT)
help: help:
@echo available targets: all run clean @echo available targets: all run clean

131
README.md
View File

@@ -1,5 +1,17 @@
# ![logo](media/EMS-ESP_logo_dark.png) # ![logo](media/EMS-ESP_logo_dark.png)
**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 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)
@@ -8,54 +20,34 @@
[![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)
[![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!
[![GitHub stars](https://img.shields.io/github/stars/emsesp/EMS-ESP32.svg?style=social&label=Star)](https://github.com/emsesp/EMS-ESP32/stargazers) [![GitHub stars](https://img.shields.io/github/stars/emsesp/EMS-ESP32.svg?style=social&label=Star)](https://github.com/emsesp/EMS-ESP32/stargazers)
[![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)
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller that communicates with **EMS** (Energy Management System) based equipment from manufacturers like Bosch, Buderus, Nefit, Junkers, Worcester and Sieger. It requires a small gateway circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built. 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.
## **Features** <img src="media/gateway-integration.jpg" width=40%>
- A multi-user, multi-language secure web interface to change settings and monitor incoming data ---
# **Features**
- A multi-user secure web interface to change settings and monitor incoming data
- A console, accessible via Serial and Telnet for more advanced monitoring - A console, accessible via Serial and Telnet for more advanced monitoring
- Native support for Home Assistant, Domoticz and openHAB via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/) - Native support for Home Assistant and Domoticz 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 [110+ EMS devices](https://emsesp.github.io/docs/All-Devices/) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways, switches, heat sources) - Support for more than [100 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways)
## **Documentation**
For the complete documentation on how to install, configure and get support visit the [EMS-ESP Wiki](https://emsesp.github.io/docs).
## **Support**
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
If you like **EMS-ESP**, please give it a star, or fork it and contribute or offer a small donation!
## **Demo** ## **Demo**
For a live demo of the Web UI click [here](https://ems-esp.derbyshire.nl) and log in with any username/password. See a demo [here](https://ems-esp.derbyshire.nl). Log in with any username/password.
## **Contributors ✨** # **Screenshots**
EMS-ESP is a project owned and maintained by [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP). ## Web Interface
## **Libraries used**
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the framework that provides the core of the Web UI
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these open source libraries
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client, with custom modifications from @MichaelDvP and @proddy
- ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
## **License**
This program is licensed under GPL-3.0
## **Screenshots**
### Web Interface
| | | | | |
| ---------------------------------- | -------------------------------- | | ---------------------------------- | -------------------------------- |
@@ -63,10 +55,75 @@ This program is licensed under GPL-3.0
| <img src="media/web_devices.png"> | <img src="media/web_mqtt.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"> | | <img src="media/web_edit.png"> | <img src="media/web_log.png"> |
### Telnet Console ## Telnet Console
<img src="media/console0.png" width=80% height=80%> <img src="media/console.png" width=80% height=80%>
### In Home Assistant ## In Home Assistant
<img src="media/ha_lovelace.png" width=80% height=80%> <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.
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)
- [Building the firmware from source code and flashing manually](https://emsesp.github.io/docs/#/Building-firmware)
# **Support Information**
If you're looking for support on **EMS-ESP** there are some options available:
## Documentation
- [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)
## 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
- [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
- [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
- [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
# **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
- providing Pull Requests (Features, Fixes, suggestions)
- testing new released features and report issues on your EMS equipment
- contributing to missing [Documentation](https://emsesp.github.io/docs)
# **Libraries used**
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the framework that provides the core of the Web UI
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these open source libraries
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for JSON
- [AsyncMqttClient](https://github.com/marvinroger/async-mqtt-client) for the MQTT client, with custom modifications from @bertmelis and @proddy
- ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
# **License**
This program is licensed under GPL-3.0

6
RELEASENOTES.md Normal file
View File

@@ -0,0 +1,6 @@
# ![logo](https://github.com/emsesp/EMS-ESP32/blob/main/media/EMS-ESP_logo_dark.png)
# Firmware Installation
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.

8
RELEASENOTES_DEV.md Normal file
View File

@@ -0,0 +1,8 @@
# ![logo](https://github.com/emsesp/EMS-ESP32/blob/main/media/EMS-ESP_logo_dark.png)
This is a snapshot of the current "beta" development code and firmware binaries for the ESP32. It has all the latest features and fixes but please be aware that this is still experimental firmware used for testing and thus may contain the odd bug. Use at your own risk and remember to report an issue if you find something unusual.
# Firmware Installation
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, , 0x2000,
app0, app, ota_0, , 0x2A0000,
app1, app, ota_1, , 0x140000,
spiffs, data, spiffs, , 64K,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0x2000
4 app0 app ota_0 0x2A0000
5 app1 app ota_1 0x140000
6 spiffs data spiffs 64K

View File

@@ -1,9 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x005000,
otadata, data, ota, , 0x002000,
boot, app, factory, , 0x280000,
app0, app, ota_0, , 0x590000,
app1, app, ota_1, , 0x590000,
nvs1, data, nvs, , 0x040000,
spiffs, data, spiffs, , 0x200000,
coredump, data, coredump,, 0x010000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x005000
3 otadata data ota 0x002000
4 boot app factory 0x280000
5 app0 app ota_0 0x590000
6 app1 app ota_1 0x590000
7 nvs1 data nvs 0x040000
8 spiffs data spiffs 0x200000
9 coredump data coredump 0x010000

View File

@@ -1,8 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x005000,
otadata, data, ota, , 0x002000,
app0, app, ota_0, , 0xDD0000,
app1, app, ota_1, , 0xDD0000,
nvs1, data, nvs, , 0x040000,
spiffs, data, spiffs, , 0x400000,
coredump, data, coredump,, 0x010000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x005000
3 otadata data ota 0x002000
4 app0 app ota_0 0xDD0000
5 app1 app ota_1 0xDD0000
6 nvs1 data nvs 0x040000
7 spiffs data spiffs 0x400000
8 coredump data coredump 0x010000

View File

@@ -1,6 +0,0 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, , 0x2000,
app0, app, ota_0, , 0x1F0000,
app1, app, ota_1, , 0x1F0000,
spiffs, data, spiffs, , 64K,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0x2000
4 app0 app ota_0 0x1F0000
5 app1 app ota_1 0x1F0000
6 spiffs data spiffs 64K

View File

@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xE000, 0x2000,
app0, app, ota_0, 0x10000, 0x1F0000,
app1, app, ota_1, 0x200000, 0x1F0000,
spiffs, data, spiffs, 0x3F0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xE000 0x2000
4 app0 app ota_0 0x10000 0x1F0000
5 app1 app ota_1 0x200000 0x1F0000
6 spiffs data spiffs 0x3F0000 0x10000

View File

@@ -7,8 +7,8 @@ build_flags =
; Access point settings ; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED -D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
-D FACTORY_AP_SSID=\"ems-esp\" -D FACTORY_AP_SSID=\"ems-esp\" ; 1-64 characters
-D FACTORY_AP_PASSWORD=\"ems-esp-neo\" -D FACTORY_AP_PASSWORD=\"ems-esp-neo\" ; 8-64 characters
-D FACTORY_AP_LOCAL_IP=\"192.168.4.1\" -D FACTORY_AP_LOCAL_IP=\"192.168.4.1\"
-D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\" -D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"
-D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\" -D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"
@@ -28,14 +28,15 @@ build_flags =
; OTA settings ; OTA settings
-D FACTORY_OTA_PORT=8266 -D FACTORY_OTA_PORT=8266
-D FACTORY_OTA_PASSWORD=\"ems-esp-neo\" -D FACTORY_OTA_PASSWORD=\"ems-esp-neo\"
-D FACTORY_OTA_ENABLED=false -D FACTORY_OTA_ENABLED=true
; MQTT settings ; MQTT settings
-D FACTORY_MQTT_ENABLED=false -D FACTORY_MQTT_ENABLED=false
-D FACTORY_MQTT_HOST=\"\" -D FACTORY_MQTT_HOST=\"test.mosquitto.org\"
-D FACTORY_MQTT_PORT=1883 -D FACTORY_MQTT_PORT=1883
-D FACTORY_MQTT_USERNAME=\"\" -D FACTORY_MQTT_USERNAME=\"\"
-D FACTORY_MQTT_PASSWORD=\"\" -D FACTORY_MQTT_PASSWORD=\"\"
-D FACTORY_MQTT_CLIENT_ID=\"ems-esp\"
-D FACTORY_MQTT_KEEP_ALIVE=60 -D FACTORY_MQTT_KEEP_ALIVE=60
-D FACTORY_MQTT_CLEAN_SESSION=false -D FACTORY_MQTT_CLEAN_SESSION=false
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128 -D FACTORY_MQTT_MAX_TOPIC_LENGTH=128

8
interface/.env Normal file
View File

@@ -0,0 +1,8 @@
# This enables lint extensions
EXTEND_ESLINT=true
# This is the name of your project. It appears on the sign-in page and in the menu bar.
REACT_APP_PROJECT_NAME=EMS-ESP
# This is the url path your project will be exposed under.
REACT_APP_PROJECT_PATH=ems-esp

View File

@@ -1,2 +0,0 @@
VITE_ALOVA_TIPS=0
REACT_APP_ALOVA_TIPS=0

3
interface/.env.hosted Normal file
View File

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

View File

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

View File

@@ -1,12 +0,0 @@
node_modules/
build/
dist/
.yarn/
.prettierrc
.eslintrc*
env.d.ts
progmem-generator.js
unpack.ts
vite.config.ts
package.json

View File

@@ -1,108 +0,0 @@
{
"env": {
"browser": true,
"es6": true
},
"extends": [
"eslint:recommended",
// "airbnb/hooks",
// "airbnb-typescript",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:prettier/recommended",
"plugin:import/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module",
"tsconfigRootDir": ".",
"project": ["tsconfig.json"]
},
"plugins": ["react", "@typescript-eslint", "autofix", "react-hooks"],
"settings": {
"import/resolver": {
"typescript": {
"project": "./tsconfig.json"
}
},
"react": {
"version": "18.x"
}
},
"rules": {
"object-shorthand": "error",
"no-console": "warn",
"@typescript-eslint/consistent-type-definitions": ["off", "type"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-enum-comparison": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-implied-eval": "off",
"@typescript-eslint/no-misused-promises": "off",
"arrow-body-style": ["error", "as-needed"],
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports"
}
],
"import/order": [
"warn",
{
"groups": ["builtin", "external", "parent", "sibling", "index", "object", "type"],
"pathGroups": [
{
"pattern": "@/**/**",
"group": "parent",
"position": "before"
}
],
"alphabetize": { "order": "asc" }
}
],
// "autofix/no-unused-vars": [
// "error",
// {
// "argsIgnorePattern": "^_",
// "ignoreRestSiblings": true,
// "destructuredArrayIgnorePattern": "^_"
// }
// ],
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/ban-types": [
"error",
{
"extendDefaults": true,
"types": {
"{}": false
}
}
],
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
]
}
}

View File

@@ -1,7 +0,0 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@@ -1,6 +0,0 @@
node_modules/
build/
dist/
.prettierrc
.yarn/
.typesafe-i18n.json

View File

@@ -1,8 +1,6 @@
{ {
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true, "singleQuote": true,
"printWidth": 120, "semi": true,
"bracketSpacing": true "trailingComma": "none",
"printWidth": 120
} }

View File

@@ -1,5 +0,0 @@
{
"adapter": "react",
"baseLocale": "pl",
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json"
}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.1.1.cjs

View File

@@ -0,0 +1,30 @@
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ProgmemGenerator = require('./progmem-generator.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = function override(config, env) {
const hosted = process.env.REACT_APP_HOSTED;
if (env === 'production' && !hosted) {
// rename the ouput file, we need it's path to be short, for embedded FS
config.output.filename = 'js/[id].[chunkhash:4].js';
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
// take out the manifest plugin
config.plugins = config.plugins.filter((plugin) => !(plugin instanceof WebpackManifestPlugin));
// shorten css filenames
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
miniCssExtractPlugin.options.chunkFilename = 'css/[id].[contenthash:4].c.css';
// don't emit license file
const terserPlugin = config.optimization.minimizer.find((plugin) => plugin instanceof TerserPlugin);
terserPlugin.options.extractComments = false;
// build progmem data files
config.plugins.push(new ProgmemGenerator({ outputPath: '../lib/framework/WWWData.h', bytesPerLine: 20 }));
}
return config;
};

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/css/roboto.css" />
<link rel="manifest" href="/app/manifest.json" />
<title>EMS-ESP</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

30939
interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,103 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.7", "version": "3.4.0",
"description": "build EMS-ESP WebUI",
"homepage": "https://emsesp.github.io/docs",
"author": "proddy",
"license": "MIT",
"private": true, "private": true,
"type": "module", "proxy": "http://localhost:3080",
"scripts": {
"build": "vite build",
"preview": "vite preview",
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"npm:mock-api\" \"vite preview\"",
"mock-api": "bun --watch ../mock-api/server.ts",
"old_mock-api": "bun --watch ../mock-api/server.js",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-api\" \"vite\"",
"old_standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:old_mock-api\" \"vite\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js",
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --cache --fix"
},
"dependencies": { "dependencies": {
"@alova/adapter-xhr": "^1.0.3", "@emotion/react": "^11.9.0",
"@babel/core": "^7.24.3", "@emotion/styled": "^11.8.1",
"@emotion/react": "^11.11.4", "@msgpack/msgpack": "^2.7.2",
"@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.8.0",
"@mui/icons-material": "^5.15.14", "@mui/material": "^5.8.1",
"@mui/material": "^5.15.14", "@table-library/react-table-library": "^3.1.4",
"@table-library/react-table-library": "4.1.7", "@types/lodash": "^4.14.182",
"@types/imagemin": "^8.0.5", "@types/node": "^17.0.36",
"@types/lodash-es": "^4.17.12", "@types/react": "^18.0.9",
"@types/node": "^20.11.30", "@types/react-dom": "^18.0.5",
"@types/react": "^18.2.72",
"@types/react-dom": "^18.2.22",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"alova": "^2.18.0", "async-validator": "^4.1.1",
"async-validator": "^4.2.5", "axios": "^0.27.2",
"history": "^5.3.0", "http-proxy-middleware": "^2.0.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^3.1.2",
"lodash-es": "^4.17.21", "lodash": "^4.17.21",
"mime-types": "^2.1.35", "notistack": "^2.0.5",
"react": "latest", "parse-ms": "^3.0.0",
"react-dom": "latest", "react": "^18.1.0",
"react-dropzone": "^14.2.3", "react-app-rewired": "^2.2.1",
"react-icons": "^5.0.1", "react-dom": "^18.1.0",
"react-router-dom": "^6.22.3", "react-dropzone": "^14.2.1",
"react-toastify": "^10.0.5", "react-icons": "^4.3.1",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"typesafe-i18n": "^5.26.2", "typescript": "^4.7.2"
"typescript": "^5.4.3" },
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"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": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"eol-last": 1,
"react/jsx-closing-bracket-location": 1,
"react/jsx-closing-tag-location": 1,
"react/jsx-wrap-multilines": 1,
"react/jsx-curly-newline": 1,
"no-multiple-empty-lines": [
1,
{
"max": 1
}
],
"no-trailing-spaces": 1,
"semi": 1,
"no-extra-semi": 1,
"react/jsx-max-props-per-line": [
1,
{
"when": "multiline"
}
],
"react/jsx-first-prop-new-line": [
1,
"multiline"
],
"@typescript-eslint/no-shadow": 1,
"max-len": [
1,
{
"code": 200
}
],
"arrow-parens": 1
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}, },
"devDependencies": { "devDependencies": {
"@preact/compat": "^17.1.2", "nodemon": "^2.0.16",
"@preact/preset-vite": "^2.8.2", "npm-run-all": "^4.1.5"
"@typescript-eslint/eslint-plugin": "^7.4.0", }
"@typescript-eslint/parser": "^7.4.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-autofix": "^1.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "alpha",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"preact": "^10.20.1",
"prettier": "^3.2.5",
"rollup-plugin-visualizer": "^5.12.0",
"terser": "^5.29.2",
"vite": "^5.2.6",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^4.3.2"
},
"packageManager": "yarn@4.1.1"
} }

View File

@@ -1,32 +1,9 @@
import { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } from 'fs'; const { resolve, relative, sep } = require('path');
import { resolve, relative, sep } from 'path'; const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
import zlib from 'zlib'; var zlib = require('zlib');
import mime from 'mime-types'; var mime = require('mime-types');
import crypto from 'crypto';
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n'; const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' ';
const outputPath = '../lib/framework/WWWData.h';
const sourcePath = './dist';
const bytesPerLine = 20;
var totalSize = 0;
const generateWWWClass = () =>
`typedef std::function<void(const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash)> RouteRegistrationHandler;
// Total size is ${totalSize} bytes
class WWWData {
${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo
.map(
(file) =>
`${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`
)
.join('\n')}
${indent.repeat(2)}}
};
`;
function getFilesSync(dir, files = []) { function getFilesSync(dir, files = []) {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
@@ -40,6 +17,10 @@ function getFilesSync(dir, files = []) {
return files; return files;
} }
function coherseToBuffer(input) {
return Buffer.isBuffer(input) ? input : Buffer.from(input);
}
function cleanAndOpen(path) { function cleanAndOpen(path) {
if (existsSync(path)) { if (existsSync(path)) {
unlinkSync(path); unlinkSync(path);
@@ -47,68 +28,94 @@ function cleanAndOpen(path) {
return createWriteStream(path, { flags: 'w+' }); return createWriteStream(path, { flags: 'w+' });
} }
class ProgmemGenerator {
constructor(options = {}) {
const { outputPath, bytesPerLine = 20, indent = ' ', includes = ARDUINO_INCLUDES } = options;
this.options = { outputPath, bytesPerLine, indent, includes };
}
apply(compiler) {
compiler.hooks.emit.tapAsync({ name: 'ProgmemGenerator' }, (compilation, callback) => {
const { outputPath, bytesPerLine, indent, includes } = this.options;
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
try {
const writeIncludes = () => {
writeStream.write(includes);
};
const writeFile = (relativeFilePath, buffer) => { const 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 + '[] = {'); writeStream.write('const uint8_t ' + variable + '[] PROGMEM = {');
// const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 }); const zipBuffer = zlib.gzipSync(buffer);
const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
// create sha
const hashSum = crypto.createHash('sha256');
hashSum.update(zipBuffer);
const hash = hashSum.digest('hex');
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()).slice(-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,
variable, variable,
size, size
hash
}); });
// console.log(relativeFilePath + ' (size ' + size + ' bytes)');
totalSize += size;
}; };
// start const writeFiles = () => {
console.log('Generating ' + outputPath + ' from ' + sourcePath);
const includes = ARDUINO_INCLUDES;
const indent = INDENT;
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(outputPath));
// includes
writeStream.write(includes);
// process static files // process static files
const buildPath = resolve(sourcePath); const buildPath = compilation.options.output.path;
for (const filePath of getFilesSync(buildPath)) { for (const filePath of getFilesSync(buildPath)) {
const readStream = readFileSync(filePath); const readStream = readFileSync(filePath);
const relativeFilePath = relative(buildPath, filePath); const relativeFilePath = relative(buildPath, filePath);
writeFile(relativeFilePath, readStream); writeFile(relativeFilePath, readStream);
} }
// process assets
const { assets } = compilation;
Object.keys(assets).forEach((relativeFilePath) => {
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
});
};
// add class const generateWWWClass = () => {
// eslint-disable-next-line max-len
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
class WWWData {
${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo
.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`)
.join('\n')}
${indent.repeat(2)}}
};
`;
};
const writeWWWClass = () => {
writeStream.write(generateWWWClass()); writeStream.write(generateWWWClass());
};
// end writeIncludes();
writeFiles();
writeWWWClass();
writeStream.on('finish', () => {
callback();
});
} finally {
writeStream.end(); writeStream.end();
}
});
}
}
console.log('Total size: ' + totalSize / 1000 + ' KB'); module.exports = ProgmemGenerator;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -1,18 +1,24 @@
/* /*
* Uses font-size 400 (normal) only and Latin (plus extra unicode chars) to keep flash memory to a minimum * Just supporting latin due to size constrains on the esp chip
* View fonts on https://fonts.google.com/ *
* Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto * The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
*
* If using light or strong typography variants you will need to add additional fonts.
*/ */
@font-face { @font-face {
font-family: 'Roboto'; font-family: 'Roboto';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
/* src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2) format('woff2'); */ src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
src: 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,
local('Roboto'), U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
local('Roboto-Regular'), }
url(../fonts/re.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144, U+0152-0153, U+015A-015B, @font-face {
U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, font-family: 'Roboto';
U+2212, U+2215, U+FEFF, U+FFFD; font-style: normal;
font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/md.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+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -1,48 +1,44 @@
import { useEffect, useState } from 'react'; import { FC, createRef, createContext, useContext, RefObject } from 'react';
import { ToastContainer, Slide } from 'react-toastify'; import { SnackbarProvider } from 'notistack';
import 'react-toastify/dist/ReactToastify.min.css'; import { IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { localStorageDetector } from 'typesafe-i18n/detectors'; import { FeaturesLoader } from './contexts/features';
import type { FC } from 'react';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme';
import TypesafeI18n from 'i18n/i18n-react'; import CustomTheme from './CustomTheme';
import { detectLocale } from 'i18n/i18n-util'; import AppRouting from './AppRouting';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
const detectedLocale = detectLocale(localStorageDetector);
const App: FC = () => { const App: FC = () => {
const [wasLoaded, setWasLoaded] = useState(false); const notistackRef: RefObject<any> = createRef();
useEffect(() => { const onClickDismiss = (key: string | number | undefined) => () => {
void loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true)); notistackRef.current.closeSnackbar(key);
}, []); };
if (!wasLoaded) return null; const ColorModeContext = createContext({ toggleColorMode: () => {} });
const colorMode = useContext(ColorModeContext);
return ( return (
<TypesafeI18n locale={detectedLocale}> <ColorModeContext.Provider value={colorMode}>
<CustomTheme> <CustomTheme>
<SnackbarProvider
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={notistackRef}
action={(key) => (
<IconButton onClick={onClickDismiss(key)} size="small">
<CloseIcon />
</IconButton>
)}
>
<FeaturesLoader>
<AppRouting /> <AppRouting />
<ToastContainer </FeaturesLoader>
position="bottom-right" </SnackbarProvider>
autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable={false}
pauseOnHover={false}
transition={Slide}
closeButton={false}
theme="light"
/>
</CustomTheme> </CustomTheme>
</TypesafeI18n> </ColorModeContext.Provider>
); );
}; };

View File

@@ -1,28 +1,27 @@
import { useContext, useEffect } from 'react'; import { FC, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
import { useSnackbar, VariantType } from 'notistack';
import { Route, Routes, Navigate, useLocation } from 'react-router-dom'; import { Authentication, AuthenticationContext } from './contexts/authentication';
import { FeaturesContext } from './contexts/features';
import { RequireAuthenticated, RequireUnauthenticated } from './components';
import { toast } from 'react-toastify'; import SignIn from './SignIn';
import type { FC } from 'react'; import AuthenticatedRouting from './AuthenticatedRouting';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
interface SecurityRedirectProps { interface SecurityRedirectProps {
message: string; message: string;
variant?: VariantType;
signOut?: boolean; signOut?: boolean;
} }
const RootRedirect: FC<SecurityRedirectProps> = ({ message, signOut }) => { const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
useEffect(() => { useEffect(() => {
signOut && authenticationContext.signOut(false); signOut && authenticationContext.signOut(false);
toast.success(message); enqueueSnackbar(message, { variant });
}, [message, signOut, authenticationContext]); }, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
return <Navigate to="/" />; return <Navigate to="/" />;
}; };
@@ -41,14 +40,15 @@ export const RemoveTrailingSlashes = () => {
}; };
const AppRouting: FC = () => { const AppRouting: FC = () => {
const { LL } = useI18nContext(); const { features } = useContext(FeaturesContext);
return ( return (
<Authentication> <Authentication>
<RemoveTrailingSlashes /> <RemoveTrailingSlashes />
<Routes> <Routes>
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} /> <Route path="/unauthorized" element={<RootRedirect message="Please sign in to continue" signOut />} />
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />} /> <Route path="/fileUpdated" element={<RootRedirect message="Upload successful" variant="success" />} />
{features.security && (
<Route <Route
path="/" path="/"
element={ element={
@@ -57,6 +57,7 @@ const AppRouting: FC = () => {
</RequireUnauthenticated> </RequireUnauthenticated>
} }
/> />
)}
<Route <Route
path="/*" path="/*"
element={ element={

View File

@@ -1,52 +1,63 @@
import { useContext, type FC } from 'react'; import { FC, useCallback, useContext, useEffect } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom'; import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import Help from './project/Help'; import { AxiosError } from 'axios';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { FeaturesContext } from './contexts/features';
import Settings from 'framework/Settings'; import * as AuthenticationApi from './api/authentication';
import AccessPoint from 'framework/ap/AccessPoint'; import { PROJECT_PATH } from './api/env';
import Mqtt from 'framework/mqtt/Mqtt'; import { AXIOS } from './api/endpoints';
import Network from 'framework/network/Network'; import { Layout, RequireAdmin } from './components';
import NetworkTime from 'framework/ntp/NetworkTime';
import OTASettings from 'framework/ota/OTASettings'; import ProjectRouting from './project/ProjectRouting';
import Security from 'framework/security/Security';
import ESPSystemStatus from 'framework/system/ESPSystemStatus'; import NetworkConnection from './framework/network/NetworkConnection';
import System from 'framework/system/System'; import AccessPoint from './framework/ap/AccessPoint';
import UploadDownload from 'framework/system/UploadDownload'; import NetworkTime from './framework/ntp/NetworkTime';
import ApplicationSettings from 'project/ApplicationSettings'; import Mqtt from './framework/mqtt/Mqtt';
import CustomEntities from 'project/CustomEntities'; import System from './framework/system/System';
import Customization from 'project/Customization'; import Security from './framework/security/Security';
import Devices from 'project/Devices';
import Scheduler from 'project/Scheduler';
import Sensors from 'project/Sensors';
const AuthenticatedRouting: FC = () => { const AuthenticatedRouting: FC = () => {
const { me } = useContext(AuthenticatedContext); const { features } = useContext(FeaturesContext);
const location = useLocation();
const navigate = useNavigate();
const handleApiResponseError = useCallback(
(error: AxiosError) => {
if (error.response && error.response.status === 401) {
AuthenticationApi.storeLoginRedirect(location);
navigate('/unauthorized');
}
return Promise.reject(error);
},
[location, navigate]
);
useEffect(() => {
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
}, [handleApiResponseError]);
return ( return (
<Layout> <Layout>
<Routes> <Routes>
<Route path="/devices/*" element={<Devices />} /> {features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
<Route path="/sensors/*" element={<Sensors />} /> <Route path="/network/*" element={<NetworkConnection />} />
<Route path="/system/*" element={<System />} /> <Route path="/ap/*" element={<AccessPoint />} />
<Route path="/help/*" element={<Help />} /> {features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
<Route path="/*" element={<Navigate to="/" />} /> {features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
{me.admin && ( {features.security && (
<> <Route
<Route path="/customizations/*" element={<Customization />} /> path="/security/*"
<Route path="/scheduler/*" element={<Scheduler />} /> element={
<Route path="/customentities/*" element={<CustomEntities />} /> <RequireAdmin>
<Route path="/settings/*" element={<Settings />} /> <Security />
<Route path="/settings/network/*" element={<Network />} /> </RequireAdmin>
<Route path="/settings/ems-esp/*" element={<ApplicationSettings />} /> }
<Route path="/settings/ap/*" element={<AccessPoint />} /> />
<Route path="/settings/ntp/*" element={<NetworkTime />} />
<Route path="/settings/mqtt/*" element={<Mqtt />} />
<Route path="/settings/ota/*" element={<OTASettings />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/settings/espsystemstatus/*" element={<ESPSystemStatus />} />
<Route path="/settings/upload/*" element={<UploadDownload />} />
</>
)} )}
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
</Routes> </Routes>
</Layout> </Layout>
); );

View File

@@ -1,18 +1,10 @@
import { FC } from 'react';
import { CssBaseline } from '@mui/material'; import { CssBaseline } from '@mui/material';
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles'; import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
import type { FC } from 'react'; import { blueGrey, blue } from '@mui/material/colors';
import type { RequiredChildrenProps } from 'utils'; import { RequiredChildrenProps } from './utils';
export const dialogStyle = {
'& .MuiDialog-paper': {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '1px'
},
backdropFilter: 'blur(1px)'
};
const theme = responsiveFontSizes( const theme = responsiveFontSizes(
createTheme({ createTheme({
@@ -22,13 +14,10 @@ const theme = responsiveFontSizes(
palette: { palette: {
mode: 'dark', mode: 'dark',
secondary: { secondary: {
main: '#2196f3' // blue[500] main: blue[500]
}, },
info: { info: {
main: '#607d8b' // blueGrey[500] main: blueGrey[500]
},
text: {
disabled: '#eee' // white
} }
} }
}) })

View File

@@ -1,38 +1,24 @@
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { useSnackbar } from 'notistack';
import { Box, Fab, Paper, Typography } from '@mui/material';
import ForwardIcon from '@mui/icons-material/Forward'; import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Paper, Typography, MenuItem, TextField, Button } from '@mui/material';
import { useRequest } from 'alova';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import type { ValidateFieldsError } from 'async-validator';
import type { Locales } from 'i18n/i18n-types'; import * as AuthenticationApi from './api/authentication';
import type { ChangeEventHandler, FC } from 'react'; import { PROJECT_NAME } from './api/env';
import type { SignInRequest } from 'types'; import { AuthenticationContext } from './contexts/authentication';
import * as AuthenticationApi from 'api/authentication';
import { PROJECT_NAME } from 'api/env';
import { ValidatedPasswordField, ValidatedTextField } from 'components'; import { AxiosError } from 'axios';
import { AuthenticationContext } from 'contexts/authentication';
import DEflag from 'i18n/DE.svg'; import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
import FRflag from 'i18n/FR.svg'; import { SignInRequest } from './types';
import GBflag from 'i18n/GB.svg'; import { ValidatedTextField } from './components';
import ITflag from 'i18n/IT.svg'; import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
import NLflag from 'i18n/NL.svg';
import NOflag from 'i18n/NO.svg';
import PLflag from 'i18n/PL.svg';
import SKflag from 'i18n/SK.svg';
import SVflag from 'i18n/SV.svg';
import TRflag from 'i18n/TR.svg';
import { I18nContext } from 'i18n/i18n-react';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn: FC = () => { const SignIn: FC = () => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
const { LL, setLocale, locale } = useContext(I18nContext);
const [signInRequest, setSignInRequest] = useState<SignInRequest>({ const [signInRequest, setSignInRequest] = useState<SignInRequest>({
username: '', username: '',
@@ -41,52 +27,37 @@ const SignIn: FC = () => {
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: callSignIn, onSuccess } = useRequest((request: SignInRequest) => AuthenticationApi.signIn(request), {
immediate: false
});
onSuccess((response) => {
if (response.data) {
authenticationContext.signIn(response.data.access_token);
}
});
const updateLoginRequestValue = updateValue(setSignInRequest); const updateLoginRequestValue = updateValue(setSignInRequest);
const signIn = async () => {
await callSignIn(signInRequest).catch((event) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
} else {
toast.error(LL.ERROR() + ' ' + event.message);
}
setProcessing(false);
});
};
const validateAndSignIn = async () => { const validateAndSignIn = async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s')
});
try { try {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest); await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
await signIn(); signIn();
} catch (errors: any) { } catch (errors: any) {
setFieldErrors(errors); setFieldErrors(errors);
setProcessing(false); setProcessing(false);
} }
}; };
const submitOnEnter = onEnterCallback(signIn); const signIn = async () => {
try {
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ target }) => { const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
const loc = target.value as Locales; authenticationContext.signIn(loginResponse.access_token);
localStorage.setItem('lang', loc); } catch (error: unknown) {
await loadLocaleAsync(loc); if (error instanceof AxiosError) {
setLocale(loc); if (error.response?.status === 401) {
enqueueSnackbar('Invalid login details', { variant: 'warning' });
}
} else {
enqueueSnackbar(extractErrorMessage(error, 'Unexpected error, please try again'), { variant: 'error' });
}
setProcessing(false);
}
}; };
const submitOnEnter = onEnterCallback(signIn);
return ( return (
<Box <Box
display="flex" display="flex"
@@ -101,91 +72,43 @@ const SignIn: FC = () => {
sx={(theme) => ({ sx={(theme) => ({
textAlign: 'center', textAlign: 'center',
padding: theme.spacing(2), padding: theme.spacing(2),
paddingTop: '172px', paddingTop: '200px',
backgroundImage: 'url("/app/icon.png")', backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2), backgroundPosition: '50% ' + theme.spacing(2),
backgroundSize: 'auto 150px',
width: '100%' width: '100%'
})} })}
> >
<Typography variant="h4">{PROJECT_NAME}</Typography> <Typography variant="h4">{PROJECT_NAME}</Typography>
<TextField name="locale" variant="outlined" value={locale} onChange={onLocaleSelected} size="small" select>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField>
<Box display="flex" flexDirection="column" alignItems="center">
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
disabled={processing} disabled={processing}
sx={{
width: 240
}}
name="username" name="username"
label={LL.USERNAME(0)} label="Username"
value={signInRequest.username} value={signInRequest.username}
onChange={updateLoginRequestValue} onChange={updateLoginRequestValue}
margin="normal" margin="normal"
variant="outlined" variant="outlined"
fullWidth
/> />
<ValidatedPasswordField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
disabled={processing} disabled={processing}
sx={{ type="password"
width: 240
}}
name="password" name="password"
label={LL.PASSWORD()} label="Password"
value={signInRequest.password} value={signInRequest.password}
onChange={updateLoginRequestValue} onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter} onKeyDown={submitOnEnter}
margin="normal"
variant="outlined" variant="outlined"
fullWidth
/> />
</Box> <Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
<Button variant="contained" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
<ForwardIcon sx={{ mr: 1 }} /> <ForwardIcon sx={{ mr: 1 }} />
{LL.SIGN_IN()} Sign In
</Button> </Fab>
</Paper> </Paper>
</Box> </Box>
); );

View File

@@ -1,7 +1,16 @@
import { alovaInstance } from './endpoints'; import { AxiosPromise } from 'axios';
import type { APSettings, APStatus } from 'types'; import { APSettings, APStatus } from '../types';
import { AXIOS } from './endpoints';
export const readAPStatus = () => alovaInstance.Get<APStatus>('/rest/apStatus'); export function readAPStatus(): AxiosPromise<APStatus> {
export const readAPSettings = () => alovaInstance.Get<APSettings>('/rest/apSettings'); return AXIOS.get('/apStatus');
export const updateAPSettings = (data: APSettings) => alovaInstance.Post<APSettings>('/rest/apSettings', data); }
export function readAPSettings(): AxiosPromise<APSettings> {
return AXIOS.get('/apSettings');
}
export function updateAPSettings(apSettings: APSettings): AxiosPromise<APSettings> {
return AXIOS.post('/apSettings', apSettings);
}

View File

@@ -1,16 +1,29 @@
import { jwtDecode } from 'jwt-decode'; import { AxiosPromise } from 'axios';
import { ACCESS_TOKEN, alovaInstance } from './endpoints'; import * as H from 'history';
import type * as H from 'history'; import jwtDecode from 'jwt-decode';
import type { Path } from 'react-router-dom'; import { Path } from 'react-router-dom';
import type { Me, SignInRequest, SignInResponse } from 'types'; import { Features, Me, SignInRequest, SignInResponse } from '../types';
import { ACCESS_TOKEN, AXIOS } from './endpoints';
import { PROJECT_PATH } from './env';
export const SIGN_IN_PATHNAME = 'loginPathname'; export const SIGN_IN_PATHNAME = 'loginPathname';
export const SIGN_IN_SEARCH = 'loginSearch'; export const SIGN_IN_SEARCH = 'loginSearch';
export const verifyAuthorization = () => alovaInstance.Get('/rest/verifyAuthorization'); export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
export const signIn = (request: SignInRequest) => alovaInstance.Post<SignInResponse>('/rest/signIn', request);
export function verifyAuthorization(): AxiosPromise<void> {
return AXIOS.get('/verifyAuthorization');
}
export function signIn(request: SignInRequest): AxiosPromise<SignInResponse> {
return AXIOS.post('/signIn', request);
}
/**
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
*/
export function getStorage() { export function getStorage() {
return localStorage || sessionStorage; return localStorage || sessionStorage;
} }
@@ -27,18 +40,18 @@ export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_SEARCH); getStorage().removeItem(SIGN_IN_SEARCH);
} }
export function fetchLoginRedirect(): Partial<Path> { export function fetchLoginRedirect(features: Features): Partial<Path> {
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();
return { return {
pathname: signInPathname || `/devices`, pathname: signInPathname || getDefaultRoute(features),
search: (signInPathname && signInSearch) || undefined search: (signInPathname && signInSearch) || undefined
}; };
} }
export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN); export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN);
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken); export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
export function addAccessTokenParameter(url: string) { export function addAccessTokenParameter(url: string) {
const accessToken = getStorage().getItem(ACCESS_TOKEN); const accessToken = getStorage().getItem(ACCESS_TOKEN);

View File

@@ -1,60 +1,105 @@
import { xhrRequestAdapter } from '@alova/adapter-xhr'; import axios, { AxiosPromise, CancelToken } from 'axios';
import { createAlova } from 'alova';
import ReactHook from 'alova/react';
import { unpack } from '../api/unpack';
import { decode } from '@msgpack/msgpack';
export const WS_BASE_URL = '/ws/';
export const API_BASE_URL = '/rest/';
export const ES_BASE_URL = '/es/';
export const EMSESP_API_BASE_URL = '/api/';
export const ACCESS_TOKEN = 'access_token'; export const ACCESS_TOKEN = 'access_token';
export const WEB_SOCKET_ROOT = calculateWebSocketRoot(WS_BASE_URL);
export const EVENT_SOURCE_ROOT = calculateEventSourceRoot(ES_BASE_URL);
const host = window.location.host; export const AXIOS = axios.create({
export const WEB_SOCKET_ROOT = 'ws://' + host + '/ws/'; baseURL: API_BASE_URL,
export const EVENT_SOURCE_ROOT = 'http://' + host + '/es/'; headers: {
'Content-Type': 'application/json'
export const alovaInstance = createAlova({
statesHook: ReactHook,
timeout: 3000, // 3 seconds but throwing a timeout error
localCache: null,
// localCache: {
// GET: {
// mode: 'placeholder', // see https://alova.js.org/learning/response-cache/#cache-replaceholder-mode
// expire: 2000
// }
// },
requestAdapter: xhrRequestAdapter(),
beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
}, },
transformRequest: [
responded: { (data, headers) => {
onSuccess: async (response) => { if (headers) {
// if (response.status === 202) { if (localStorage.getItem(ACCESS_TOKEN)) {
// throw new Error('Wait'); // wifi scan in progress headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data = await response.data;
if (response.data instanceof ArrayBuffer) {
return unpack(data);
} }
if (headers['Content-Type'] !== 'application/json') {
return data; return data;
} }
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
} }
return JSON.stringify(data);
}
]
}); });
export const alovaInstanceGH = createAlova({ export const AXIOS_API = axios.create({
baseURL: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases', baseURL: EMSESP_API_BASE_URL,
statesHook: ReactHook, headers: {
requestAdapter: xhrRequestAdapter() 'Content-Type': 'application/json'
},
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
]
}); });
export const AXIOS_BIN = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
responseType: 'arraybuffer',
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
],
transformResponse: [
(data) => {
return decode(data);
}
]
});
function calculateWebSocketRoot(webSocketPath: string) {
const location = window.location;
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
return webProtocol + '//' + location.host + webSocketPath;
}
function calculateEventSourceRoot(endpointPath: string) {
const location = window.location;
return location.protocol + '//' + location.host + endpointPath;
}
export interface FileUploadConfig {
cancelToken?: CancelToken;
onUploadProgress?: (progressEvent: ProgressEvent) => void;
}
export const startUploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise<void> => {
const formData = new FormData();
formData.append('file', file);
return AXIOS.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...(config || {})
});
};

View File

@@ -1 +1,2 @@
export const PROJECT_NAME = 'EMS-ESP'; export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME || 'EMS-ESP';
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH || 'project';

View File

@@ -0,0 +1,9 @@
import { AxiosPromise } from 'axios';
import { Features } from '../types';
import { AXIOS } from './endpoints';
export function readFeatures(): AxiosPromise<Features> {
return AXIOS.get('/features');
}

View File

@@ -1,7 +1,16 @@
import { alovaInstance } from './endpoints'; import { AxiosPromise } from 'axios';
import type { MqttSettingsType, MqttStatusType } from 'types'; import { MqttSettings, MqttStatus } from '../types';
export const readMqttStatus = () => alovaInstance.Get<MqttStatusType>('/rest/mqttStatus'); import { AXIOS } from './endpoints';
export const readMqttSettings = () => alovaInstance.Get<MqttSettingsType>('/rest/mqttSettings');
export const updateMqttSettings = (data: MqttSettingsType) => export function readMqttStatus(): AxiosPromise<MqttStatus> {
alovaInstance.Post<MqttSettingsType>('/rest/mqttSettings', data); return AXIOS.get('/mqttStatus');
}
export function readMqttSettings(): AxiosPromise<MqttSettings> {
return AXIOS.get('/mqttSettings');
}
export function updateMqttSettings(ntpSettings: MqttSettings): AxiosPromise<MqttSettings> {
return AXIOS.post('/mqttSettings', ntpSettings);
}

View File

@@ -1,15 +1,25 @@
import { alovaInstance } from './endpoints'; import { AxiosPromise } from 'axios';
import type { WiFiNetworkList, NetworkSettings, NetworkStatus } from 'types'; import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
export const readNetworkStatus = () => alovaInstance.Get<NetworkStatus>('/rest/networkStatus'); import { AXIOS } from './endpoints';
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
export const listNetworks = () => export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', { return AXIOS.get('/networkStatus');
name: 'listNetworks', }
timeout: 20000 // timeout 20 seconds
}); export function scanNetworks(): AxiosPromise<void> {
export const readNetworkSettings = () => return AXIOS.get('/scanNetworks');
alovaInstance.Get<NetworkSettings>('/rest/networkSettings', { name: 'networkSettings' }); }
export const updateNetworkSettings = (wifiSettings: NetworkSettings) =>
alovaInstance.Post<NetworkSettings>('/rest/networkSettings', wifiSettings); export function listNetworks(): AxiosPromise<WiFiNetworkList> {
return AXIOS.get('/listNetworks');
}
export function readNetworkSettings(): AxiosPromise<NetworkSettings> {
return AXIOS.get('/networkSettings');
}
export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise<NetworkSettings> {
return AXIOS.post('/networkSettings', wifiSettings);
}

View File

@@ -1,11 +1,20 @@
import { alovaInstance } from './endpoints'; import { AxiosPromise } from 'axios';
import type { NTPSettings, NTPStatus, Time } from 'types'; import { NTPSettings, NTPStatus, Time } from '../types';
export const readNTPStatus = () => alovaInstance.Get<NTPStatus>('/rest/ntpStatus'); import { AXIOS } from './endpoints';
export const readNTPSettings = () =>
alovaInstance.Get<NTPSettings>('/rest/ntpSettings', {
name: 'ntpSettings'
});
export const updateNTPSettings = (data: NTPSettings) => alovaInstance.Post<NTPSettings>('/rest/ntpSettings', data);
export const updateTime = (data: Time) => alovaInstance.Post<Time>('/rest/time', data); export function readNTPStatus(): AxiosPromise<NTPStatus> {
return AXIOS.get('/ntpStatus');
}
export function readNTPSettings(): AxiosPromise<NTPSettings> {
return AXIOS.get('/ntpSettings');
}
export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
return AXIOS.post('/ntpSettings', ntpSettings);
}
export function updateTime(time: Time): AxiosPromise<Time> {
return AXIOS.post('/time', time);
}

View File

@@ -1,13 +1,17 @@
import { alovaInstance } from './endpoints'; import { AxiosPromise } from 'axios';
import type { SecuritySettings, Token } from 'types'; import { SecuritySettings, Token } from '../types';
export const readSecuritySettings = () => alovaInstance.Get<SecuritySettings>('/rest/securitySettings'); import { AXIOS } from './endpoints';
export const updateSecuritySettings = (securitySettings: SecuritySettings) => export function readSecuritySettings(): AxiosPromise<SecuritySettings> {
alovaInstance.Post('/rest/securitySettings', securitySettings); return AXIOS.get('/securitySettings');
}
export const generateToken = (username?: string) => export function updateSecuritySettings(securitySettings: SecuritySettings): AxiosPromise<SecuritySettings> {
alovaInstance.Get<Token>('/rest/generateToken', { return AXIOS.post('/securitySettings', securitySettings);
params: { username } }
});
export function generateToken(username?: string): AxiosPromise<Token> {
return AXIOS.get('/generateToken', { params: { username } });
}

View File

@@ -1,45 +1,41 @@
import { alovaInstance, alovaInstanceGH } from './endpoints'; import { AxiosPromise } from 'axios';
import type { OTASettings, SystemStatus, LogSettings, ESPSystemStatus } from 'types';
// ESPSystemStatus - also used to ping in Restart monitor for pinging import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
export const readESPSystemStatus = () => alovaInstance.Get<ESPSystemStatus>('/rest/ESPSystemStatus');
// SystemStatus import { AXIOS, AXIOS_BIN, FileUploadConfig, startUploadFile } from './endpoints';
export const readSystemStatus = () => alovaInstance.Get<SystemStatus>('/rest/systemStatus');
// commands export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
export const restart = () => alovaInstance.Post('/rest/restart'); return AXIOS.get('/systemStatus', { timeout });
export const partition = () => alovaInstance.Post('/rest/partition'); }
export const factoryReset = () => alovaInstance.Post('/rest/factoryReset');
export function restart(): AxiosPromise<void> {
// OTA return AXIOS.post('/restart');
export const readOTASettings = () => alovaInstance.Get<OTASettings>(`/rest/otaSettings`); }
export const updateOTASettings = (data: any) => alovaInstance.Post('/rest/otaSettings', data);
export function factoryReset(): AxiosPromise<void> {
// SystemLog return AXIOS.post('/factoryReset');
export const readLogSettings = () => alovaInstance.Get<LogSettings>(`/rest/logSettings`); }
export const updateLogSettings = (data: any) => alovaInstance.Post('/rest/logSettings', data);
export const fetchLog = () => alovaInstance.Post('/rest/fetchLog'); export function readOTASettings(): AxiosPromise<OTASettings> {
return AXIOS.get('/otaSettings');
// Get versions from github }
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', { export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
transformData(response: any) { return AXIOS.post('/otaSettings', otaSettings);
return response.data.name.substring(1); }
export const uploadFile = (file: File, config?: FileUploadConfig): AxiosPromise<void> =>
startUploadFile('/uploadFile', file, config);
export function readLogSettings(): AxiosPromise<LogSettings> {
return AXIOS.get('/logSettings');
}
export function updateLogSettings(logSettings: LogSettings): AxiosPromise<LogSettings> {
return AXIOS.post('/logSettings', logSettings);
}
export function readLogEntries(): AxiosPromise<LogEntries> {
return AXIOS_BIN.get('/fetchLog');
} }
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
transformData(response: any) {
return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
}
});
export const uploadFile = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000, // override timeout for uploading firmware - 1 minute
enableUpload: true
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { Box } from '@mui/material'; import { FC } from 'react';
import type { BoxProps } from '@mui/material'; import { Box, BoxProps } from '@mui/material';
import type { FC } from 'react';
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => ( const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => {
return (
<Box <Box
sx={{ sx={{
'& button, & a, & .MuiCard-root': { '& button, & a, & .MuiCard-root': {
@@ -21,5 +21,6 @@ const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => (
{children} {children}
</Box> </Box>
); );
};
export default ButtonRow; export default ButtonRow;

View File

@@ -1,10 +1,11 @@
import { FC } from 'react';
import { Box, BoxProps, SvgIconProps, Theme, Typography, useTheme } from '@mui/material';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import { Box, Typography, useTheme } from '@mui/material'; import ErrorIcon from '@mui/icons-material/Error';
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
import type { FC } from 'react';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error'; type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';

View File

@@ -1,10 +1,12 @@
import { Paper, Divider } from '@mui/material'; import { FC } from 'react';
import type { FC } from 'react';
import type { RequiredChildrenProps } from 'utils'; import { Paper, Divider } from '@mui/material';
import { RequiredChildrenProps } from '../utils';
interface SectionContentProps extends RequiredChildrenProps { interface SectionContentProps extends RequiredChildrenProps {
title?: string; title: string;
titleGutter?: boolean;
id?: string; id?: string;
} }
@@ -12,9 +14,7 @@ const SectionContent: FC<SectionContentProps> = (props) => {
const { children, title, id } = props; const { children, title, id } = props;
return ( return (
<Paper id={id} sx={{ p: 2, m: 2 }}> <Paper id={id} sx={{ p: 2, m: 2 }}>
{title && (
<Divider sx={{ pb: 2, borderColor: 'primary.main', fontSize: 20, color: 'primary.main' }}>{title}</Divider> <Divider sx={{ pb: 2, borderColor: 'primary.main', fontSize: 20, color: 'primary.main' }}>{title}</Divider>
)}
{children} {children}
</Paper> </Paper>
); );

View File

@@ -6,4 +6,3 @@ export * from './upload';
export { default as SectionContent } from './SectionContent'; export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow'; export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox'; export { default as MessageBox } from './MessageBox';
export { default as BlockNavigation } from './routing/BlockNavigation';

View File

@@ -1,6 +1,5 @@
import { FormControlLabel } from '@mui/material'; import { FC } from 'react';
import type { FormControlLabelProps } from '@mui/material'; import { FormControlLabel, FormControlLabelProps } from '@mui/material';
import type { FC } from 'react';
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => ( const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div> <div>

View File

@@ -1,11 +1,10 @@
import { FC, useState } from 'react';
import { IconButton, InputAdornment } from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { IconButton, InputAdornment } from '@mui/material';
import { useState } from 'react';
import ValidatedTextField from './ValidatedTextField'; import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
import type { ValidatedTextFieldProps } from './ValidatedTextField';
import type { FC } from 'react';
type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>; type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
@@ -20,7 +19,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ InputProps, .
...InputProps, ...InputProps,
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end"> <IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />} {showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>

View File

@@ -1,7 +1,7 @@
import { FormHelperText, TextField } from '@mui/material'; import { FC } from 'react';
import type { TextFieldProps } from '@mui/material'; import { ValidateFieldsError } from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react'; import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
interface ValidatedFieldProps { interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError; fieldErrors?: ValidateFieldsError;

View File

@@ -1,15 +1,16 @@
import { Box, Toolbar } from '@mui/material'; import { FC, useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import LayoutAppBar from './LayoutAppBar';
import { Box, Toolbar } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import { RequiredChildrenProps } from '../../utils';
import LayoutDrawer from './LayoutDrawer'; import LayoutDrawer from './LayoutDrawer';
import LayoutAppBar from './LayoutAppBar';
import { LayoutContext } from './context'; import { LayoutContext } from './context';
import type { FC } from 'react';
import type { RequiredChildrenProps } from 'utils'; export const DRAWER_WIDTH = 240;
import { PROJECT_NAME } from 'api/env';
export const DRAWER_WIDTH = 210;
const Layout: FC<RequiredChildrenProps> = ({ children }) => { const Layout: FC<RequiredChildrenProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);

View File

@@ -1,15 +1,23 @@
import MenuIcon from '@mui/icons-material/Menu'; import { FC, useContext } from 'react';
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
import type { FC } from 'react';
export const DRAWER_WIDTH = 210; import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LayoutAuthMenu from './LayoutAuthMenu';
import { FeaturesContext } from '../../contexts/features';
export const DRAWER_WIDTH = 240;
interface LayoutAppBarProps { interface LayoutAppBarProps {
title: string; title: string;
onToggleDrawer: () => void; onToggleDrawer: () => void;
} }
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => ( const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return (
<AppBar <AppBar
position="fixed" position="fixed"
sx={{ sx={{
@@ -20,14 +28,23 @@ const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => (
}} }}
> >
<Toolbar> <Toolbar>
<IconButton color="inherit" edge="start" onClick={onToggleDrawer} sx={{ mr: 2, display: { md: 'none' } }}> <IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6" noWrap component="div"> <Typography variant="h6" noWrap component="div">
{title} {title}
</Typography> </Typography>
<Box flexGrow={1} />
{features.security && <LayoutAuthMenu />}
</Toolbar> </Toolbar>
</AppBar> </AppBar>
); );
};
export default LayoutAppBar; export default LayoutAppBar;

View File

@@ -0,0 +1,73 @@
import { FC, useState, useContext } from 'react';
import { Box, Button, Divider, IconButton, Popover, Typography, Avatar, styled, TypographyProps } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import { AuthenticatedContext } from '../../contexts/authentication';
const ItemTypography = styled(Typography)<TypographyProps>({
maxWidth: '250px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
});
const LayoutAuthMenu: FC = () => {
const { me, signOut } = useContext(AuthenticatedContext);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = anchorEl ? 'app-menu-popover' : undefined;
return (
<>
<IconButton id="open-auth-menu" sx={{ padding: 0 }} aria-describedby={id} color="inherit" onClick={handleClick}>
<AccountCircleIcon />
</IconButton>
<Popover
id="app-menu-popover"
sx={{ mt: 1 }}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<Box display="flex" flexDirection="row" alignItems="center" p={2}>
<Avatar sx={{ width: 80, height: 80 }}>
<PersonIcon fontSize="large" />
</Avatar>
<Box pl={2}>
<ItemTypography variant="h6">{me.username}</ItemTypography>
<ItemTypography variant="body1">{me.admin ? 'Admin User' : 'Guest User'}</ItemTypography>
</Box>
</Box>
<Divider />
<Box p={1.5}>
<Button variant="outlined" fullWidth color="primary" onClick={() => signOut(true)}>
Sign Out
</Button>
</Box>
</Popover>
</>
);
};
export default LayoutAuthMenu;

View File

@@ -1,11 +1,11 @@
import { FC } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { DRAWER_WIDTH } from './Layout';
import { PROJECT_NAME } from '../../api/env';
import LayoutMenu from './LayoutMenu'; import LayoutMenu from './LayoutMenu';
import { DRAWER_WIDTH } from './Layout';
import type { FC } from 'react';
import { PROJECT_NAME } from 'api/env';
const LayoutDrawerLogo = styled('img')(({ theme }) => ({ const LayoutDrawerLogo = styled('img')(({ theme }) => ({
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
@@ -13,7 +13,7 @@ const LayoutDrawerLogo = styled('img')(({ theme }) => ({
marginRight: theme.spacing(2) marginRight: theme.spacing(2)
}, },
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up('sm')]: {
height: 38, height: 36,
marginRight: theme.spacing(2) marginRight: theme.spacing(2)
} }
})); }));
@@ -29,7 +29,9 @@ const LayoutDrawer: FC<LayoutDrawerProps> = ({ mobileOpen, onClose }) => {
<Toolbar disableGutters> <Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}> <Box display="flex" alignItems="center" px={2}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} /> <LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6">{PROJECT_NAME}</Typography> <Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
</Box> </Box>
<Divider absolute /> <Divider absolute />
</Toolbar> </Toolbar>

View File

@@ -1,254 +1,40 @@
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import { FC, useContext } from 'react';
import AssessmentIcon from '@mui/icons-material/Assessment';
import CategoryIcon from '@mui/icons-material/Category'; import { Divider, List } from '@mui/material';
import ConstructionIcon from '@mui/icons-material/Construction';
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import LiveHelpIcon from '@mui/icons-material/LiveHelp'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import MoreTimeIcon from '@mui/icons-material/MoreTime'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import PersonIcon from '@mui/icons-material/Person';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import LockIcon from '@mui/icons-material/Lock';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import { import { FeaturesContext } from '../../contexts/features';
Divider, import ProjectMenu from '../../project/ProjectMenu';
List,
Box,
Button,
Popover,
Avatar,
MenuItem,
TextField,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText
} from '@mui/material';
import { useContext, useState } from 'react'; import LayoutMenuItem from './LayoutMenuItem';
import type { Locales } from 'i18n/i18n-types'; import { AuthenticatedContext } from '../../contexts/authentication';
import type { FC, ChangeEventHandler } from 'react';
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import DEflag from 'i18n/DE.svg';
import FRflag from 'i18n/FR.svg';
import GBflag from 'i18n/GB.svg';
import ITflag from 'i18n/IT.svg';
import NLflag from 'i18n/NL.svg';
import NOflag from 'i18n/NO.svg';
import PLflag from 'i18n/PL.svg';
import SKflag from 'i18n/SK.svg';
import SVflag from 'i18n/SV.svg';
import TRflag from 'i18n/TR.svg';
import { I18nContext } from 'i18n/i18n-react';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
const LayoutMenu: FC = () => { const LayoutMenu: FC = () => {
const { me, signOut } = useContext(AuthenticatedContext); const { features } = useContext(FeaturesContext);
const { locale, LL, setLocale } = useContext(I18nContext); const authenticatedContext = useContext(AuthenticatedContext);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const id = anchorEl ? 'app-menu-popover' : undefined;
const [menuOpen, setMenuOpen] = useState(true);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
const handleClick = (event: any) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return ( return (
<> <>
<List component="nav"> {features.project && (
<LayoutMenuItem icon={CategoryIcon} label={LL.DEVICES()} to={`/devices`} /> <List disablePadding component="nav">
<LayoutMenuItem icon={SensorsIcon} label={LL.SENSORS()} to={`/sensors`} /> <ProjectMenu />
<Divider /> <Divider />
</List>
<Box
sx={{
bgcolor: menuOpen ? 'rgba(71, 98, 130, 0.2)' : null,
pb: menuOpen ? 2 : 0
}}
>
<ListItemButton
alignItems="flex-start"
onClick={() => setMenuOpen(!menuOpen)}
sx={{
pt: 2.5,
pb: menuOpen ? 0 : 2.5,
'&:hover, &:focus': { '& svg': { opacity: 1 } }
}}
>
<ListItemText
// TODO: translate
primary="Customize"
primaryTypographyProps={{
fontWeight: '600',
mb: '2px',
color: 'lightblue'
}}
// TODO: translate
secondary="Customizations, Scheduler and Custom Entities"
secondaryTypographyProps={{
noWrap: true,
fontSize: 12,
color: menuOpen ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0.5)'
}}
sx={{ my: 0 }}
/>
<KeyboardArrowDown
sx={{
mr: -1,
opacity: 0,
transform: menuOpen ? 'rotate(-180deg)' : 'rotate(0)',
transition: '0.2s'
}}
/>
</ListItemButton>
{menuOpen && (
<>
<LayoutMenuItem
icon={ConstructionIcon}
label={LL.CUSTOMIZATIONS()}
disabled={!me.admin}
to={`/customizations`}
/>
<LayoutMenuItem icon={MoreTimeIcon} label={LL.SCHEDULER()} disabled={!me.admin} to={`/scheduler`} />
<LayoutMenuItem
icon={PlaylistAddIcon}
label={LL.CUSTOM_ENTITIES(0)}
disabled={!me.admin}
to={`/customentities`}
/>
</>
)} )}
</Box> <List disablePadding component="nav">
<LayoutMenuItem icon={SettingsEthernetIcon} label="Network Connection" to="/network" />
<LayoutMenuItem icon={SettingsInputAntennaIcon} label="Access Point" to="/ap" />
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />}
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
</List> </List>
<List style={{ marginTop: `auto` }}>
<LayoutMenuItem icon={AssessmentIcon} label={LL.SYSTEM(0)} to="/system" />
<LayoutMenuItem icon={SettingsIcon} label={LL.SETTINGS(0)} disabled={!me.admin} to="/settings" />
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP_OF('')} to={`/help`} />
</List>
<Divider />
<List>
<ListItem disablePadding onClick={handleClick}>
<ListItemButton>
<ListItemIcon>
<AccountCircleIcon />
</ListItemIcon>
<ListItemText>{me.username}</ListItemText>
</ListItemButton>
</ListItem>
</List>
<Popover
id={id}
sx={{ mt: 1 }}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<Box
p={2}
sx={{
borderRadius: 2,
border: '2px solid grey'
}}
>
<List>
<ListItem disablePadding>
<Avatar sx={{ bgcolor: '#b1395f', color: 'white' }}>
<PersonIcon />
</Avatar>
<ListItemText
sx={{ pl: 2 }}
primary={me.username}
secondary={(me.admin ? LL.ADMIN() : LL.GUEST()) + ' Account'}
/>
</ListItem>
</List>
<Box p={2}>
<TextField
name="locale"
InputProps={{ style: { fontSize: 10 } }}
variant="outlined"
value={locale}
onChange={onLocaleSelected}
size="small"
select
>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField>
</Box>
<Box>
<Button variant="outlined" fullWidth color="primary" onClick={() => signOut(true)}>
{LL.SIGN_OUT()}
</Button>
</Box>
</Box>
</Popover>
</> </>
); );
}; };

View File

@@ -1,9 +1,11 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { FC } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import type { SvgIconProps } from '@mui/material';
import type { FC } from 'react';
import { routeMatches } from 'utils'; import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
import { grey } from '@mui/material/colors';
import { routeMatches } from '../../utils';
interface LayoutMenuItemProps { interface LayoutMenuItemProps {
icon: React.ComponentType<SvgIconProps>; icon: React.ComponentType<SvgIconProps>;
@@ -15,15 +17,15 @@ interface LayoutMenuItemProps {
const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => { const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = routeMatches(to, pathname);
return ( return (
<ListItemButton component={Link} to={to} disabled={disabled} selected={selected}> <ListItem disablePadding selected={routeMatches(to, pathname)}>
<ListItemIcon sx={{ color: selected ? '#90caf9' : '#9e9e9e' }}> <ListItemButton component={Link} to={to} disabled={disabled}>
<ListItemIcon sx={{ color: grey[500] }}>
<Icon /> <Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText sx={{ color: selected ? '#90caf9' : '#f5f5f5' }}>{label}</ListItemText> <ListItemText>{label}</ListItemText>
</ListItemButton> </ListItemButton>
</ListItem>
); );
}; };

View File

@@ -1,52 +0,0 @@
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Avatar, ListItem, ListItemAvatar, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { Link } from 'react-router-dom';
import type { SvgIconProps } from '@mui/material';
import type { FC } from 'react';
interface ListMenuItemProps {
icon: React.ComponentType<SvgIconProps>;
bgcolor?: string;
label: string;
text: string;
to?: string;
disabled?: boolean;
}
function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
return (
<>
<ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}>
<Icon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={label} secondary={text} />
</>
);
}
const LayoutMenuItem: FC<ListMenuItemProps> = ({ icon, bgcolor, label, text, to, disabled }) => (
<>
{to && !disabled ? (
<ListItem
disablePadding
secondaryAction={
<ListItemIcon style={{ justifyContent: 'right', color: 'lightblue', verticalAlign: 'middle' }}>
<NavigateNextIcon />
</ListItemIcon>
}
>
<ListItemButton component={Link} to={to}>
<RenderIcon icon={icon} bgcolor={bgcolor} label={label} text={text} to="" />
</ListItemButton>
</ListItem>
) : (
<ListItem>
<RenderIcon icon={icon} bgcolor={bgcolor} label={label} text={text} to="" />
</ListItem>
)}
</>
);
export default LayoutMenuItem;

View File

@@ -1,6 +1,7 @@
import WarningIcon from '@mui/icons-material/Warning'; import { FC } from 'react';
import { Box, Paper, Typography } from '@mui/material'; import { Box, Paper, Typography } from '@mui/material';
import type { FC } from 'react'; import WarningIcon from '@mui/icons-material/Warning';
interface ApplicationErrorProps { interface ApplicationErrorProps {
message?: string; message?: string;

View File

@@ -1,10 +1,9 @@
import RefreshIcon from '@mui/icons-material/Refresh'; import { FC } from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material'; import { Box, Button, CircularProgress, Typography } from '@mui/material';
import type { FC } from 'react'; import RefreshIcon from '@mui/icons-material/Refresh';
import { MessageBox } from 'components'; import { MessageBox } from '..';
import { useI18nContext } from 'i18n/i18n-react';
interface FormLoaderProps { interface FormLoaderProps {
message?: string; message?: string;
@@ -13,14 +12,12 @@ interface FormLoaderProps {
} }
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, onRetry, message = 'Loading…' }) => { const FormLoader: FC<FormLoaderProps> = ({ errorMessage, onRetry, message = 'Loading…' }) => {
const { LL } = useI18nContext();
if (errorMessage) { if (errorMessage) {
return ( return (
<MessageBox my={2} level="error" message={errorMessage}> <MessageBox my={2} level="error" message={errorMessage}>
{onRetry && ( {onRetry && (
<Button startIcon={<RefreshIcon />} variant="contained" color="error" onClick={onRetry}> <Button startIcon={<RefreshIcon />} variant="contained" color="error" onClick={onRetry}>
{LL.RETRY()} Retry
</Button> </Button>
)} )}
</MessageBox> </MessageBox>

View File

@@ -1,17 +1,12 @@
import { CircularProgress, Box, Typography } from '@mui/material'; import { FC } from 'react';
import type { Theme } from '@mui/material';
import type { FC } from 'react';
import { useI18nContext } from 'i18n/i18n-react'; import { CircularProgress, Box, Typography, Theme } from '@mui/material';
interface LoadingSpinnerProps { interface LoadingSpinnerProps {
height?: number | string; height?: number | string;
} }
const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => { const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => (
const { LL } = useI18nContext();
return (
<Box display="flex" alignItems="center" justifyContent="center" flexDirection="column" padding={2} height={height}> <Box display="flex" alignItems="center" justifyContent="center" flexDirection="column" padding={2} height={height}>
<CircularProgress <CircularProgress
sx={(theme: Theme) => ({ sx={(theme: Theme) => ({
@@ -21,10 +16,9 @@ const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => {
size={100} size={100}
/> />
<Typography variant="h4" color="textSecondary"> <Typography variant="h4" color="textSecondary">
{LL.LOADING()}&hellip; Loading&hellip;
</Typography> </Typography>
</Box> </Box>
); );
};
export default LoadingSpinner; export default LoadingSpinner;

View File

@@ -1,32 +0,0 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import type { FC } from 'react';
import type { unstable_Blocker as Blocker } from 'react-router-dom';
import { dialogStyle } from 'CustomTheme';
import { useI18nContext } from 'i18n/i18n-react';
interface BlockNavigationProps {
blocker: Blocker;
}
const BlockNavigation: FC<BlockNavigationProps> = ({ blocker }) => {
const { LL } = useI18nContext();
return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => blocker.reset?.()} color="secondary">
{LL.STAY()}
</Button>
<Button variant="contained" onClick={() => blocker.proceed?.()} color="primary">
{LL.LEAVE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default BlockNavigation;

View File

@@ -1,9 +1,8 @@
import { useContext } from 'react'; import { FC, useContext } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import type { FC } from 'react';
import type { RequiredChildrenProps } from 'utils'; import { AuthenticatedContext } from '../../contexts/authentication';
import { AuthenticatedContext } from 'contexts/authentication'; import { RequiredChildrenProps } from '../../utils';
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => { const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
const authenticatedContext = useContext(AuthenticatedContext); const authenticatedContext = useContext(AuthenticatedContext);

View File

@@ -1,12 +1,14 @@
import { useContext, useEffect } from 'react'; import { FC, useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import type { AuthenticatedContextValue } from 'contexts/authentication/context'; import {
import type { FC } from 'react'; AuthenticatedContext,
AuthenticatedContextValue,
AuthenticationContext
} from '../../contexts/authentication/context';
import { storeLoginRedirect } from '../../api/authentication';
import type { RequiredChildrenProps } from 'utils'; import { RequiredChildrenProps } from '../../utils';
import { storeLoginRedirect } from 'api/authentication';
import { AuthenticatedContext, AuthenticationContext } from 'contexts/authentication/context';
const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => { const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);

View File

@@ -1,15 +1,16 @@
import { useContext } from 'react'; import { FC, useContext } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import type { FC } from 'react';
import type { RequiredChildrenProps } from 'utils'; import * as AuthenticationApi from '../../api/authentication';
import * as AuthenticationApi from 'api/authentication'; import { AuthenticationContext } from '../../contexts/authentication';
import { AuthenticationContext } from 'contexts/authentication'; import { RequiredChildrenProps } from '../../utils';
import { FeaturesContext } from '../../contexts/features';
const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => { const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect()} /> : <>{children}</>; return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
}; };
export default RequireUnauthenticated; export default RequireUnauthenticated;

View File

@@ -1,8 +1,9 @@
import { Tabs, useMediaQuery, useTheme } from '@mui/material'; import React, { FC } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { FC } from 'react';
import type { RequiredChildrenProps } from 'utils'; import { Tabs, useMediaQuery, useTheme } from '@mui/material';
import { RequiredChildrenProps } from '../../utils';
interface RouterTabsProps extends RequiredChildrenProps { interface RouterTabsProps extends RequiredChildrenProps {
value: string | false; value: string | false;
@@ -14,7 +15,7 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (_event: any, path: string) => { const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
navigate(path); navigate(path);
}; };

View File

@@ -1,8 +1,9 @@
import { useMatch, useResolvedPath } from 'react-router-dom'; import { useMatch, useResolvedPath } from 'react-router-dom';
export const useRouterTab = () => { export const useRouterTab = () => {
const routerTabPathMatch = useMatch(useResolvedPath(':tab').pathname); const routerTabPath = useResolvedPath(':tab');
const routerTab = routerTabPathMatch?.params?.tab || false; const routerTabPathMatch = useMatch(routerTabPath.pathname);
const routerTab = routerTabPathMatch?.params?.tab || false;
return { routerTab } as const; return { routerTab } as const;
}; };

View File

@@ -1,14 +1,12 @@
import CancelIcon from '@mui/icons-material/Cancel'; import { FC, Fragment } from 'react';
import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import { useDropzone, DropzoneState } from 'react-dropzone';
import { Box, Button, LinearProgress, Typography, useTheme } from '@mui/material';
import { Fragment } from 'react';
import { useDropzone } from 'react-dropzone';
import type { Theme } from '@mui/material';
import type { Progress } from 'alova';
import type { FC } from 'react';
import type { DropzoneState } from 'react-dropzone';
import { useI18nContext } from 'i18n/i18n-react'; import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CancelIcon from '@mui/icons-material/Cancel';
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
const getBorderColor = (theme: Theme, props: DropzoneState) => { const getBorderColor = (theme: Theme, props: DropzoneState) => {
if (props.isDragAccept) { if (props.isDragAccept) {
@@ -26,49 +24,43 @@ const getBorderColor = (theme: Theme, props: DropzoneState) => {
export interface SingleUploadProps { export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void; onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void; onCancel: () => void;
isUploading: boolean; uploading: boolean;
progress: Progress; progress?: ProgressEvent;
} }
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, isUploading, progress }) => { const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, uploading, progress }) => {
const uploading = isUploading && progress.total > 0;
const dropzoneState = useDropzone({ const dropzoneState = useDropzone({
onDrop, onDrop,
accept: { accept: {
'application/octet-stream': ['.bin'], 'application/octet-stream': ['.bin'],
'application/json': ['.json'], 'application/json': ['.json']
'text/plain': ['.md5']
}, },
disabled: isUploading, disabled: uploading,
multiple: false multiple: false
}); });
const { getRootProps, getInputProps } = dropzoneState; const { getRootProps, getInputProps } = dropzoneState;
const theme = useTheme(); const theme = useTheme();
const { LL } = useI18nContext();
const progressText = () => { const progressText = () => {
if (uploading) { if (uploading) {
if (progress.total && progress.loaded) { if (progress?.lengthComputable) {
return progress.loaded <= progress.total return `Uploading: ${progressPercentage(progress)}%`;
? LL.UPLOADING() + ': ' + Math.round((progress.loaded * 100) / progress.total) + '%'
: LL.UPLOADING() + ': ' + Math.round((progress.total * 100) / progress.loaded) + '%';
} }
return 'Uploading\u2026';
} }
return LL.UPLOAD_DROP_TEXT(); return 'Drop file or click here';
}; };
return ( return (
<Box <Box
{...getRootProps({ {...getRootProps({
sx: { sx: {
py: 4, py: 8,
px: 2, px: 2,
borderWidth: 2, borderWidth: 2,
borderRadius: 2, borderRadius: 2,
borderStyle: 'dashed', borderStyle: 'dashed',
color: theme.palette.grey[400], color: theme.palette.grey[700],
transition: 'border .24s ease-in-out', transition: 'border .24s ease-in-out',
width: '100%', width: '100%',
cursor: uploading ? 'default' : 'pointer', cursor: uploading ? 'default' : 'pointer',
@@ -84,18 +76,12 @@ const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, isUploading, pr
<Fragment> <Fragment>
<Box width="100%" p={2}> <Box width="100%" p={2}>
<LinearProgress <LinearProgress
variant="determinate" variant={!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'}
value={ value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
progress.total === 0 || progress.loaded === 0
? 0
: progress.loaded <= progress.total
? Math.round((progress.loaded * 100) / progress.total)
: Math.round((progress.total * 100) / progress.loaded)
}
/> />
</Box> </Box>
<Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}> <Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}>
{LL.CANCEL()} Cancel
</Button> </Button>
</Fragment> </Fragment>
)} )}

View File

@@ -1 +1,2 @@
export { default as SingleUpload } from './SingleUpload'; export { default as SingleUpload } from './SingleUpload';
export { default as useFileUpload } from './useFileUpload';

View File

@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from 'react';
import axios, { AxiosPromise, CancelTokenSource } from 'axios';
import { useSnackbar } from 'notistack';
import { extractErrorMessage } from '../../utils';
import { FileUploadConfig } from '../../api/endpoints';
interface MediaUploadOptions {
upload: (file: File, config?: FileUploadConfig) => AxiosPromise<void>;
}
const useFileUpload = ({ upload }: MediaUploadOptions) => {
const { enqueueSnackbar } = useSnackbar();
const [uploading, setUploading] = useState<boolean>(false);
const [uploadProgress, setUploadProgress] = useState<ProgressEvent>();
const [uploadCancelToken, setUploadCancelToken] = useState<CancelTokenSource>();
const resetUploadingStates = () => {
setUploading(false);
setUploadProgress(undefined);
setUploadCancelToken(undefined);
};
const cancelUpload = useCallback(() => {
uploadCancelToken?.cancel();
resetUploadingStates();
}, [uploadCancelToken]);
useEffect(() => {
return () => {
uploadCancelToken?.cancel();
};
}, [uploadCancelToken]);
const uploadFile = async (images: File[]) => {
try {
const cancelToken = axios.CancelToken.source();
setUploadCancelToken(cancelToken);
setUploading(true);
await upload(images[0], {
onUploadProgress: setUploadProgress,
cancelToken: cancelToken.token
});
resetUploadingStates();
enqueueSnackbar('File uploaded', { variant: 'success' });
} catch (error: unknown) {
if (axios.isCancel(error)) {
enqueueSnackbar('Upload aborted', { variant: 'warning' });
} else {
resetUploadingStates();
enqueueSnackbar(extractErrorMessage(error, 'Upload failed'), { variant: 'error' });
}
}
};
return [uploadFile, cancelUpload, uploading, uploadProgress] as const;
};
export default useFileUpload;

View File

@@ -1,69 +1,67 @@
import { useRequest } from 'alova'; import { FC, useCallback, useContext, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useSnackbar } from 'notistack';
import { redirect } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { AuthenticationContext } from './context';
import type { FC } from 'react';
import type { Me } from 'types'; import * as AuthenticationApi from '../../api/authentication';
import type { RequiredChildrenProps } from 'utils'; import { ACCESS_TOKEN } from '../../api/endpoints';
import * as AuthenticationApi from 'api/authentication'; import { RequiredChildrenProps } from '../../utils';
import { ACCESS_TOKEN } from 'api/endpoints'; import { LoadingSpinner } from '../../components';
import { LoadingSpinner } from 'components'; import { Me } from '../../types';
import { useI18nContext } from 'i18n/i18n-react'; import { FeaturesContext } from '../features';
import { AuthenticationContext } from './context';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => { const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { LL } = useI18nContext(); const { features } = useContext(FeaturesContext);
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const [initialized, setInitialized] = useState<boolean>(false); const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>(); const [me, setMe] = useState<Me>();
const { send: verifyAuthorization } = useRequest(AuthenticationApi.verifyAuthorization(), {
immediate: false
});
const signIn = (accessToken: string) => { const signIn = (accessToken: string) => {
try { try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken); AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken); const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe); setMe(decodedMe);
toast.success(LL.LOGGED_IN({ name: decodedMe.username })); enqueueSnackbar(`Logged in as ${decodedMe.username}`, { variant: 'success' });
} catch (error) { } catch (error: unknown) {
setMe(undefined); setMe(undefined);
throw new Error('Failed to parse JWT'); throw new Error('Failed to parse JWT');
} }
}; };
const signOut = (doRedirect: boolean) => { const signOut = (redirect: boolean) => {
AuthenticationApi.clearAccessToken(); AuthenticationApi.clearAccessToken();
setMe(undefined); setMe(undefined);
if (doRedirect) { if (redirect) {
// navigate('/'); navigate('/');
redirect('/');
} }
}; };
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!features.security) {
setMe({ admin: true, username: 'admin' });
setInitialized(true);
return;
}
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
await verifyAuthorization() try {
.then(() => { await AuthenticationApi.verifyAuthorization();
setMe(AuthenticationApi.decodeMeJWT(accessToken)); setMe(AuthenticationApi.decodeMeJWT(accessToken));
setInitialized(true); setInitialized(true);
}) } catch (error: unknown) {
.catch(() => {
setMe(undefined); setMe(undefined);
setInitialized(true); setInitialized(true);
}); }
} else { } else {
setMe(undefined); setMe(undefined);
setInitialized(true); setInitialized(true);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [features]);
}, []);
useEffect(() => { useEffect(() => {
void refresh(); refresh();
}, [refresh]); }, [refresh]);
if (initialized) { if (initialized) {

View File

@@ -1,5 +1,5 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { Me } from 'types'; import { Me } from '../../types';
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
refresh: () => Promise<void>; refresh: () => Promise<void>;

View File

@@ -0,0 +1,47 @@
import { FC, useCallback, useEffect, useState } from 'react';
import * as FeaturesApi from '../../api/features';
import { extractErrorMessage, RequiredChildrenProps } from '../../utils';
import { Features } from '../../types';
import { ApplicationError, LoadingSpinner } from '../../components';
import { FeaturesContext } from '.';
const FeaturesLoader: FC<RequiredChildrenProps> = (props) => {
const [errorMessage, setErrorMessage] = useState<string>();
const [features, setFeatures] = useState<Features>();
const loadFeatures = useCallback(async () => {
try {
const response = await FeaturesApi.readFeatures();
setFeatures(response.data);
} catch (error: unknown) {
setErrorMessage(extractErrorMessage(error, 'Failed to fetch application details.'));
}
}, []);
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
if (features) {
return (
<FeaturesContext.Provider
value={{
features
}}
>
{props.children}
</FeaturesContext.Provider>
);
}
if (errorMessage) {
return <ApplicationError message={errorMessage} />;
}
return <LoadingSpinner height="100vh" />;
};
export default FeaturesLoader;

View File

@@ -0,0 +1,10 @@
import { createContext } from 'react';
import { Features } from '../../types';
export interface FeaturesContextValue {
features: Features;
}
const FeaturesContextDefaultValue = {} as FeaturesContextValue;
export const FeaturesContext = createContext(FeaturesContextDefaultValue);

View File

@@ -0,0 +1,2 @@
export * from './context';
export { default as FeaturesLoader } from './FeaturesLoader';

View File

@@ -1,250 +0,0 @@
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import CastIcon from '@mui/icons-material/Cast';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock';
import MemoryIcon from '@mui/icons-material/Memory';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune';
import { List, Button, Dialog, DialogActions, DialogContent, DialogTitle, Box } from '@mui/material';
import { useRequest } from 'alova';
import { useState, type FC } from 'react';
import { toast } from 'react-toastify';
import RestartMonitor from './system/RestartMonitor';
import { dialogStyle } from 'CustomTheme';
import * as SystemApi from 'api/system';
import { ButtonRow, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { useI18nContext } from 'i18n/i18n-react';
const Settings: FC = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0));
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: restartCommand } = useRequest(SystemApi.restart(), {
immediate: false
});
const { send: factoryResetCommand } = useRequest(SystemApi.factoryReset(), {
immediate: false
});
const { send: partitionCommand } = useRequest(SystemApi.partition(), {
immediate: false
});
const restart = async () => {
setProcessing(true);
await restartCommand()
.then(() => {
setRestarting(true);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setConfirmRestart(false);
setProcessing(false);
});
};
const factoryReset = async () => {
setProcessing(true);
await factoryResetCommand()
.then(() => {
setRestarting(true);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setConfirmFactoryReset(false);
setProcessing(false);
});
};
const partition = async () => {
setProcessing(true);
await partitionCommand()
.then(() => {
setRestarting(true);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setConfirmRestart(false);
setProcessing(false);
});
};
const renderRestartDialog = () => (
<Dialog sx={dialogStyle} open={confirmRestart} onClose={() => setConfirmRestart(false)}>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmRestart(false)}
disabled={processing}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={restart}
disabled={processing}
color="primary"
>
{LL.RESTART()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={partition}
disabled={processing}
color="primary"
>
EMS-ESP Loader
</Button>
</DialogActions>
</Dialog>
);
const renderFactoryResetDialog = () => (
<Dialog sx={dialogStyle} open={confirmFactoryReset} onClose={() => setConfirmFactoryReset(false)}>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
disabled={processing}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={factoryReset}
disabled={processing}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
{/* TODO: translate */}
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION_SETTINGS()}
text="Modify EMS-ESP Application Settings"
to="ems-esp"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(0)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(0))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME())}
to="ntp"
/>
<ListMenuItem icon={DeviceHubIcon} bgcolor="#68374d" label="MQTT" text={LL.CONFIGURE('MQTT')} to="mqtt" />
<ListMenuItem icon={CastIcon} bgcolor="#efc34b" label="OTA" text={LL.CONFIGURE('OTA')} to="ota" />
{/* TODO: translate */}
<ListMenuItem icon={LockIcon} label={LL.SECURITY(0)} text="Add/Remove Users" to="security" />
<ListMenuItem
icon={MemoryIcon}
bgcolor="#b1395f"
label={LL.STATUS_OF('ESP32')}
text="ESP32 Information"
to="espsystemstatus"
/>
{/* TODO: translate */}
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.UPLOAD_DOWNLOAD()}
text="Upload/Download Settings and Firmware"
to="upload"
/>
</List>
{renderRestartDialog()}
{renderFactoryResetDialog()}
<Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
<ButtonRow>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="primary"
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
</ButtonRow>
</Box>
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</ButtonRow>
</Box>
</Box>
</>
);
return <SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>;
};
export default Settings;

View File

@@ -1,54 +1,37 @@
import CancelIcon from '@mui/icons-material/Cancel'; import { FC, useState } from 'react';
import WarningIcon from '@mui/icons-material/Warning'; import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox, MenuItem } from '@mui/material'; import { range } from 'lodash';
import { range } from 'lodash-es';
import { useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import type { APSettingsType } from 'types'; import { Button, Checkbox, MenuItem } from '@mui/material';
import * as APApi from 'api/ap'; import SaveIcon from '@mui/icons-material/Save';
import { createAPSettingsValidator, validate } from '../../validators';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
ButtonRow, ButtonRow,
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField, ValidatedTextField
BlockNavigation } from '../../components';
} from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { APProvisionMode, APSettings } from '../../types';
import { APProvisionMode } from 'types'; import { numberValue, updateValue, useRest } from '../../utils';
import { numberValue, updateValueDirty, useRest } from 'utils'; import * as APApi from '../../api/ap';
import { createAPSettingsValidator, validate } from 'validators'; export const isAPEnabled = ({ provision_mode }: APSettings) => {
return provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
};
export const isAPEnabled = ({ provision_mode }: APSettingsType) => const APSettingsForm: FC = () => {
provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; const { loadData, saving, data, setData, saveData, errorMessage } = useRest<APSettings>({
const APSettings: FC = () => {
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<APSettingsType>({
read: APApi.readAPSettings, read: APApi.readAPSettings,
update: APApi.updateAPSettings update: APApi.updateAPSettings
}); });
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue); const updateFormValue = updateValue(setData);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -59,7 +42,7 @@ const APSettings: FC = () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data); await validate(createAPSettingsValidator(data), data);
await saveData(); saveData();
} catch (errors: any) { } catch (errors: any) {
setFieldErrors(errors); setFieldErrors(errors);
} }
@@ -70,7 +53,7 @@ const APSettings: FC = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="provision_mode" name="provision_mode"
label={LL.AP_PROVIDE() + '...'} label="Provide Access Point&hellip;"
value={data.provision_mode} value={data.provision_mode}
fullWidth fullWidth
select select
@@ -78,16 +61,16 @@ const APSettings: FC = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>{LL.AP_PROVIDE_TEXT_1()}</MenuItem> <MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>Always</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>{LL.AP_PROVIDE_TEXT_2()}</MenuItem> <MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>When WiFi Disconnected</MenuItem>
<MenuItem value={APProvisionMode.AP_NEVER}>{LL.AP_PROVIDE_TEXT_3()}</MenuItem> <MenuItem value={APProvisionMode.AP_NEVER}>Never</MenuItem>
</ValidatedTextField> </ValidatedTextField>
{isAPEnabled(data) && ( {isAPEnabled(data) && (
<> <>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="ssid" name="ssid"
label={LL.ACCESS_POINT(2) + ' SSID'} label="Access Point SSID"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.ssid} value={data.ssid}
@@ -97,7 +80,7 @@ const APSettings: FC = () => {
<ValidatedPasswordField <ValidatedPasswordField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="password" name="password"
label={LL.ACCESS_POINT(2) + ' ' + LL.PASSWORD()} label="Access Point Password"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.password} value={data.password}
@@ -107,7 +90,7 @@ const APSettings: FC = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="channel" name="channel"
label={LL.AP_PREFERRED_CHANNEL()} label="Preferred Channel"
value={numberValue(data.channel)} value={numberValue(data.channel)}
fullWidth fullWidth
select select
@@ -124,12 +107,12 @@ const APSettings: FC = () => {
</ValidatedTextField> </ValidatedTextField>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="ssid_hidden" checked={data.ssid_hidden} onChange={updateFormValue} />} control={<Checkbox name="ssid_hidden" checked={data.ssid_hidden} onChange={updateFormValue} />}
label={LL.AP_HIDE_SSID()} label="Hide SSID"
/> />
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="max_clients" name="max_clients"
label={LL.AP_MAX_CLIENTS()} label="Max Clients"
value={numberValue(data.max_clients)} value={numberValue(data.max_clients)}
fullWidth fullWidth
select select
@@ -147,7 +130,7 @@ const APSettings: FC = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="local_ip" name="local_ip"
label={LL.AP_LOCAL_IP()} label="Local IP"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.local_ip} value={data.local_ip}
@@ -157,7 +140,7 @@ const APSettings: FC = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="gateway_ip" name="gateway_ip"
label={LL.NETWORK_GATEWAY()} label="Gateway"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.gateway_ip} value={data.gateway_ip}
@@ -167,7 +150,7 @@ const APSettings: FC = () => {
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="subnet_mask" name="subnet_mask"
label={LL.NETWORK_SUBNET()} label="Subnet"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.subnet_mask} value={data.subnet_mask}
@@ -176,40 +159,27 @@ const APSettings: FC = () => {
/> />
</> </>
)} )}
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<CancelIcon />} startIcon={<SaveIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="primary"
type="submit" type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit} onClick={validateAndSubmit}
> >
{LL.APPLY_CHANGES(dirtyFlags.length)} Save
</Button> </Button>
</ButtonRow> </ButtonRow>
)}
</> </>
); );
}; };
return ( return (
<SectionContent> <SectionContent title="Access Point Settings" titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );
}; };
export default APSettings; export default APSettingsForm;

View File

@@ -1,20 +1,17 @@
import ComputerIcon from '@mui/icons-material/Computer'; import { FC } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh'; import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, useTheme } from '@mui/material'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import { useRequest } from 'alova'; import ComputerIcon from '@mui/icons-material/Computer';
import type { Theme } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh';
import type { FC } from 'react';
import type { APStatusType } from 'types'; import * as APApi from '../../api/ap';
import * as APApi from 'api/ap'; import { APNetworkStatus, APStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from 'components'; import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { useRest } from '../../utils';
import { useI18nContext } from 'i18n/i18n-react'; export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
import { APNetworkStatus } from 'types';
export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
switch (status) { switch (status) {
case APNetworkStatus.ACTIVE: case APNetworkStatus.ACTIVE:
return theme.palette.success.main; return theme.palette.success.main;
@@ -27,29 +24,27 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
} }
}; };
const APStatus: FC = () => { export const apStatus = ({ status }: APStatus) => {
const { data: data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) { switch (status) {
case APNetworkStatus.ACTIVE: case APNetworkStatus.ACTIVE:
return LL.ACTIVE(); return 'Active';
case APNetworkStatus.INACTIVE: case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0); return 'Inactive';
case APNetworkStatus.LINGERING: case APNetworkStatus.LINGERING:
return 'Lingering until idle'; return 'Lingering until idle';
default: default:
return LL.UNKNOWN(); return 'Unknown';
} }
}; };
const APStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<APStatus>({ read: APApi.readAPStatus });
const theme = useTheme();
const content = () => { const content = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />; return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
} }
return ( return (
@@ -61,14 +56,14 @@ const APStatus: FC = () => {
<SettingsInputAntennaIcon /> <SettingsInputAntennaIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} /> <ListItemText primary="Status" secondary={apStatus(data)} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar>IP</Avatar> <Avatar>IP</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} /> <ListItemText primary="IP Address" secondary={data.ip_address} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -77,7 +72,7 @@ const APStatus: FC = () => {
<DeviceHubIcon /> <DeviceHubIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('MAC')} 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>
@@ -86,20 +81,24 @@ const APStatus: FC = () => {
<ComputerIcon /> <ComputerIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} /> <ListItemText primary="AP Clients" secondary={data.station_num} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
<ButtonRow> <ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}> <Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
{LL.REFRESH()} Refresh
</Button> </Button>
</ButtonRow> </ButtonRow>
</> </>
); );
}; };
return <SectionContent>{content()}</SectionContent>; return (
<SectionContent title="Access Point Status" titleGutter>
{content()}
</SectionContent>
);
}; };
export default APStatus; export default APStatusForm;

View File

@@ -1,30 +1,37 @@
import { Tab } from '@mui/material'; import { FC, useContext } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom'; import { Navigate, Routes, Route } from 'react-router-dom';
import APSettings from './APSettings'; import { Tab } from '@mui/material';
import APStatus from './APStatus';
import type { FC } from 'react';
import { RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { AuthenticatedContext } from '../../contexts/authentication';
import APStatusForm from './APStatusForm';
import APSettingsForm from './APSettingsForm';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
const AccessPoint: FC = () => { const AccessPoint: FC = () => {
const { LL } = useI18nContext(); useLayoutTitle('Access Point');
useLayoutTitle(LL.ACCESS_POINT(0)); const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab(); const { routerTab } = useRouterTab();
return ( return (
<> <>
<RouterTabs value={routerTab}> <RouterTabs value={routerTab}>
<Tab value="settings" label={LL.SETTINGS_OF(LL.ACCESS_POINT(1))} /> <Tab value="status" label="Access Point Status" />
<Tab value="status" label={LL.STATUS_OF(LL.ACCESS_POINT(1))} /> <Tab value="settings" label="Access Point Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs> </RouterTabs>
<Routes> <Routes>
<Route path="status" element={<APStatus />} /> <Route path="status" element={<APStatusForm />} />
<Route path="settings" element={<APSettings />} /> <Route
<Route path="*" element={<Navigate replace to="settings" />} /> path="settings"
element={
<RequireAdmin>
<APSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes> </Routes>
</> </>
); );

View File

@@ -1,30 +1,37 @@
import { Tab } from '@mui/material'; import React, { FC, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import MqttSettings from './MqttSettings';
import MqttStatus from './MqttStatus';
import type { FC } from 'react';
import { RouterTabs, useLayoutTitle, useRouterTab } from 'components'; import { Tab } from '@mui/material';
import { useI18nContext } from 'i18n/i18n-react'; import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import MqttStatusForm from './MqttStatusForm';
import MqttSettingsForm from './MqttSettingsForm';
const Mqtt: FC = () => { const Mqtt: FC = () => {
const { LL } = useI18nContext();
useLayoutTitle('MQTT'); useLayoutTitle('MQTT');
const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab(); const { routerTab } = useRouterTab();
return ( return (
<> <>
<RouterTabs value={routerTab}> <RouterTabs value={routerTab}>
<Tab value="settings" label={LL.SETTINGS_OF('MQTT')} /> <Tab value="status" label="MQTT Status" />
<Tab value="status" label={LL.STATUS_OF('MQTT')} /> <Tab value="settings" label="MQTT Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs> </RouterTabs>
<Routes> <Routes>
<Route path="status" element={<MqttStatus />} /> <Route path="status" element={<MqttStatusForm />} />
<Route path="settings" element={<MqttSettings />} /> <Route
<Route path="*" element={<Navigate replace to="settings" />} /> path="settings"
element={
<RequireAdmin>
<MqttSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes> </Routes>
</> </>
); );

View File

@@ -1,474 +0,0 @@
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment, TextField } from '@mui/material';
import { useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import type { MqttSettingsType } from 'types';
import * as MqttApi from 'api/mqtt';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
BlockNavigation
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { createMqttSettingsValidator, validate } from 'validators';
const MqttSettings: FC = () => {
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<MqttSettingsType>({
read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings
});
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />}
label={LL.ENABLE_MQTT()}
/>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
fullWidth
multiline
variant="outlined"
value={data.host}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
label="Port"
fullWidth
variant="outlined"
value={numberValue(data.port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="base"
label={LL.BASE_TOPIC()}
fullWidth
variant="outlined"
value={data.base}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
fullWidth
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
name="username"
label={LL.USERNAME(0)}
fullWidth
variant="outlined"
value={data.username}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<ValidatedPasswordField
name="password"
label={LL.PASSWORD()}
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="keep_alive"
label="Keep Alive"
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.keep_alive)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
name="mqtt_qos"
label="QoS"
value={data.mqtt_qos}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>0</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
</TextField>
</Grid>
</Grid>
{data.enableTLS !== undefined && (
<BlockFormControlLabel
control={<Checkbox name="enableTLS" checked={data.enableTLS} onChange={updateFormValue} />}
label={LL.ENABLE_TLS()}
/>
)}
{data.enableTLS === true && (
<ValidatedPasswordField
name="rootCA"
label={LL.CERT()}
fullWidth
variant="outlined"
value={data.rootCA}
onChange={updateFormValue}
margin="normal"
/>
)}
<BlockFormControlLabel
control={<Checkbox name="clean_session" checked={data.clean_session} onChange={updateFormValue} />}
label={LL.MQTT_CLEAN_SESSION()}
/>
<BlockFormControlLabel
control={<Checkbox name="mqtt_retain" checked={data.mqtt_retain} onChange={updateFormValue} />}
label={LL.MQTT_RETAIN_FLAG()}
/>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.FORMATTING()}
</Typography>
<TextField
name="nested_format"
label={LL.MQTT_FORMAT()}
value={data.nested_format}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={1}>{LL.MQTT_NEST_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_NEST_2()}</MenuItem>
</TextField>
<BlockFormControlLabel
control={<Checkbox name="send_response" checked={data.send_response} onChange={updateFormValue} />}
label={LL.MQTT_RESPONSE()}
/>
{!data.ha_enabled && (
<Grid
container
rowSpacing={-1}
spacing={1}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item>
<BlockFormControlLabel
control={<Checkbox name="publish_single" checked={data.publish_single} onChange={updateFormValue} />}
label={LL.MQTT_PUBLISH_TEXT_1()}
/>
</Grid>
{data.publish_single && (
<Grid item>
<BlockFormControlLabel
control={
<Checkbox name="publish_single2cmd" checked={data.publish_single2cmd} onChange={updateFormValue} />
}
label={LL.MQTT_PUBLISH_TEXT_2()}
/>
</Grid>
)}
</Grid>
)}
{!data.publish_single && (
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item>
<BlockFormControlLabel
control={<Checkbox name="ha_enabled" checked={data.ha_enabled} onChange={updateFormValue} />}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<Grid
container
sx={{ pl: 1 }}
spacing={1}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>Home Assistant</MenuItem>
<MenuItem value={1}>Domoticz</MenuItem>
<MenuItem value={2}>Domoticz (latest)</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
fullWidth
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</TextField>
</Grid>
</Grid>
)}
</Grid>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={12} sm={6} md={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_heartbeat"
label="Heartbeat"
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_heartbeat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_sensor"
label={LL.TEMP_SENSORS()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_other"
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
label={LL.DEFAULT(0)}
fullWidth
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
</>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);
};
export default MqttSettings;

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