43 Commits

Author SHA1 Message Date
Proddy
0f6ad533f6 Merge pull request #1157 from MichaelDvP/patch
Some fixes to patch
2023-04-12 19:50:11 +02:00
MichaelDvP
4ae68fc3fd fix esp32s3 upload 2023-04-11 11:21:49 +02:00
MichaelDvP
b97a9e3e5b mqtt resend timeout from 10 min to 10 sec 2023-04-11 11:20:34 +02:00
MichaelDvP
e79e104837 fix nofrost mode enum order 2023-04-11 11:19:19 +02:00
MichaelDvP
7851e83162 fix heartbeat interval, #1131 2023-04-11 11:18:24 +02:00
MichaelDvP
389f0bd2e8 rebuild HA-mqtt on language change 2023-04-11 11:17:20 +02:00
MichaelDvP
a4bbbb460a add Greenstar HIU 2023-04-11 11:16:02 +02:00
Proddy
d01697a065 Merge pull request #1115 from proddy/patch
Patch
2023-03-11 17:06:19 +01:00
Proddy
a027d17a7d ready for 3.5.2 2023-03-11 16:07:18 +01:00
Proddy
a27787d0d5 update doc 2023-03-11 15:47:07 +01:00
Proddy
24baf844fd Merge pull request #1111 from MichaelDvP/patch
Last minute fix
2023-03-10 16:52:59 +05:30
MichaelDvP
6f125b7fbb Merge branch 'emsesp:patch' into patch 2023-03-10 12:09:54 +01:00
Proddy
51b00cb280 Merge pull request #1110 from proddy/patch
fix binary_sensor mqtt topic for sensors
2023-03-10 15:33:47 +05:30
Proddy
31ffa8483e fix binary_sensor mqtt topic for sensors 2023-03-10 11:03:36 +01:00
MichaelDvP
e7cbd97662 Merge branch 'patch' of https://github.com/MichaelDvP/EMS-ESP32 into patch 2023-03-07 07:16:36 +01:00
MichaelDvP
0b0ca1efd1 fix avty 2023-03-07 07:15:49 +01:00
Proddy
4518396833 Merge pull request #1100 from MichaelDvP/patch
fix rc35 hc active detection
2023-03-04 16:02:15 +01:00
MichaelDvP
8de6448b74 fix rc35 hc active detection 2023-03-04 11:13:05 +01:00
Proddy
6f1b65e70b Merge pull request #1089 from proddy/patch
show device type name in dialog
2023-02-28 17:56:52 +01:00
Proddy
b93890440a show device type name in dialog 2023-02-28 17:56:24 +01:00
Proddy
7cee4916a2 Merge pull request #1086 from MichaelDvP/patch
patches
2023-02-28 08:43:35 +01:00
MichaelDvP
3e7b9a7dd9 Merge patch 2023-02-28 07:32:19 +01:00
Proddy
069c63b55a Update version.h 2023-02-27 15:17:58 +01:00
MichaelDvP
e9bd1d4b81 set heatingTemp, burnMaxPower,... 2023-02-24 16:40:24 +01:00
MichaelDvP
1fa1ee5b24 add option for start boiler with forced heating off 2023-02-24 15:52:16 +01:00
MichaelDvP
8800b88f62 new entity forceHeatingOff sends every minute 0x1A 2023-02-24 11:04:43 +01:00
MichaelDvP
fd6df7279b no ha_entitiy_config remove 2023-02-23 10:33:19 +01:00
MichaelDvP
0dceb25569 Merge branch 'dev' of https://github.com/pswid/EMS-ESP32 into dev_ 2023-02-23 09:17:29 +01:00
MichaelDvP
96d5324945 solar valve VS1 2023-02-23 09:04:21 +01:00
pswid
971df73f13 added "availability" section in HA Discovery
This will allow to not remove discovery topics during each ems-esp restart (MQTT init), so it can solve issue #910.

After applying this fix (and removing from the code commands that delete discovery topics) HA no longer reports errors/warnings in the log. I'm testing if for over a week.
Now it is enough to delete discovery topics only when it is really needed (e.g. the entity has been removed by customization, discovery prefix has been changed or the HA option has been disabled in the configuration).
2023-02-22 13:16:05 +01:00
MichaelDvP
24a4cb85ff analogsensor HA discovery: types on remove 2023-02-21 15:20:30 +01:00
MichaelDvP
7f9582d01a no restart for change entity-language 2023-02-21 15:17:41 +01:00
MichaelDvP
dfa5e23e90 HA config recreate #1067 2023-02-21 13:56:31 +01:00
MichaelDvP
016e18002c recreate HA discovery by device 2023-02-21 07:44:43 +01:00
MichaelDvP
e0b89ae3ed Updatecheck to flash, not to filesize 2023-02-21 07:41:01 +01:00
MichaelDvP
daad2ffe6c fix flowTemp, etc for HM200, #500 2023-02-18 11:45:29 +01:00
MichaelDvP
a9caadaf5e add hm200 entities #500 2023-02-18 10:42:18 +01:00
MichaelDvP
0d8d750e46 fix mqtt-discovery for analog sensor commands #1035 2023-02-17 08:40:46 +01:00
MichaelDvP
7a394c8e89 add Tado thermostat, old version device 0x19. no entities, no broadcast 2023-02-15 10:04:44 +01:00
MichaelDvP
bcf83616f8 changelog, version 3.5.1-dev.0 2023-02-12 17:30:34 +01:00
MichaelDvP
32bf13ca9a fix #954, write selflowtemp with same value 2023-02-12 17:30:00 +01:00
MichaelDvP
608500e417 fix RC30_N hc detection #786 2023-02-12 17:28:44 +01:00
MichaelDvP
bfe498758c weblog buffer without large json 2023-02-12 17:27:23 +01:00
827 changed files with 85833 additions and 93415 deletions

View File

@@ -1,32 +1,32 @@
--- ---
name: Problem Report/Change Request name: Problem Report
about: Create a Report to help us improve about: Create a Report to help us improve
--- ---
<!-- Thanks for reporting an issue for this project. READ THIS FIRST: <!-- Thanks for reporting a problem for this project. READ THIS FIRST:
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 issue might already be solved. The latest precompiled binaries of EMS-ESP can be downloaded from https://github.com/emsesp/EMS-ESP32/releases/tag/latest 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
Please take a few minutes to complete the requested information below. Please take a few minutes to complete the requested information below.
--> -->
### DESCRIPTION ### PROBLEM DESCRIPTION
_A clear and concise description of what the problem is or the change requested._ _A clear and concise description of what the problem is._
### REQUESTED INFORMATION ### REQUESTED INFORMATION
_Make sure your have performed every step and checked the applicable boxes before submitting your issue. Thank you!_ _Make sure your have performed every step and checked the applicable boxes before submitting your issue. Thank you!_
- [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues) - [ ] Searched the problem in [issues](https://github.com/emsesp/EMS-ESP32/issues)
- [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions) - [ ] Searched the problem in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
- [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/) - [ ] Searched the problem in the [docs](https://emsesp.github.io/docs/Troubleshooting/)
- [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT) - [ ] Searched the problem in the [chat](https://discord.gg/3J3GgnzpyT)
- [ ] Provide the System information in the area below, taken from `http://<IP>/api/system` - [ ] Provide the output of http://ems-esp.local/api/system :
```json ```lua
Paste System information here.... System information output here:
``` ```
@@ -41,10 +41,10 @@ _A clear and concise description of what you expected to happen._
### SCREENSHOTS ### SCREENSHOTS
_If applicable, add screenshots to help explain your issue._ _If applicable, add screenshots to help explain your problem._
### ADDITIONAL CONTEXT ### ADDITIONAL CONTEXT
_Add any other context about the issue here._ _Add any other context about the problem here._
**(Please remember to close the issue when it has been addressed)** **(Please, remember to close the issue when the problem has been addressed)**

View File

@@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: EMS-ESP Docs - name: EMS-ESP Docs
url: https://emsesp.org url: https://emsesp.github.io/docs/
about: All the information related to EMS-ESP. about: All the information related to EMS-ESP.
- name: EMS-ESP Discussions and Support - name: EMS-ESP Discussions and Support
url: https://github.com/emsesp/EMS-ESP32/discussions url: https://github.com/emsesp/EMS-ESP32/discussions

View File

@@ -1,25 +0,0 @@
name: 'github-releases-to-discord'
on:
workflow_dispatch:
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,33 +1,26 @@
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:
- name: Checkout repository
uses: actions/checkout@v4
- name: Enable Corepack - uses: actions/checkout@v3
run: corepack enable - uses: actions/setup-python@v4
- uses: actions/setup-node@v3
- name: Install python 3.11
uses: actions/setup-python@v5
with: with:
python-version: '3.11' node-version: '16'
- name: Install Node.js 20 - name: Get EMS-ESP source code and version
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: Get EMS-ESP 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}'`
@@ -41,24 +34,24 @@ jobs:
- name: Build WebUI - name: Build WebUI
run: | run: |
cd interface cd interface
yarn install npm ci
yarn typesafe-i18n --no-watch npx typesafe-i18n --no-watch
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build npm run build
yarn webUI
- name: Build all PIO target environments from default_envs - name: Build firmware
run: | run: |
platformio run platformio run -e ci
- name: Create GitHub Release - name: Create a GH Release
id: 'automatic_releases' id: "automatic_releases"
uses: emsesp/action-automatic-releases@v1.0.0 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

@@ -1,34 +1,57 @@
# see https://github.com/marketplace/actions/sonarcloud-scan-for-c-and-c#usage
name: Sonar Check name: Sonar Check
on: on:
push: push:
branches: branches:
- dev - dev
# pull_request: pull_request:
# types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
jobs: jobs:
build: build:
name: Build and analyze name: Build
if: github.repository == 'emsesp/EMS-ESP32'
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository_owner == 'emsesp'
# 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:
- name: Checkout repository - uses: actions/checkout@v2
uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 11
- name: Install sonar-scanner and build-wrapper uses: actions/setup-java@v1
uses: SonarSource/sonarcloud-github-c-cpp@v2 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: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all run: |
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: sonar-scanner --define sonar.cfamily.compile-commands="${{ env.BUILD_WRAPPER_OUT_DIR }}/compile_commands.json" run: |
sonar-scanner

View File

@@ -1,54 +1,47 @@
name: 'tagged-release' name: "tagged-release"
on: on:
workflow_dispatch:
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:
- name: Checkout repository
uses: actions/checkout@v4
- name: Enable Corepack - uses: actions/checkout@v2
run: corepack enable - uses: actions/setup-python@v2
- uses: actions/setup-node@v2
- name: Install python 3.11
uses: actions/setup-python@v5
with: with:
python-version: '3.11' node-version: '16'
- name: Install Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20.x'
- 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
pio pkg update
- name: Build WebUI - name: Build WebUI
run: | run: |
cd interface cd interface
yarn install npm ci
yarn typesafe-i18n --no-watch npx typesafe-i18n --no-watch
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build npm run build
yarn webUI
- name: Build all PIO target environments from default_envs - name: Build firmware
run: | run: |
platformio run platformio run -e ci
- name: Create GitHub Release - name: Release
uses: emsesp/action-automatic-releases@v1.0.0 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,54 +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
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: '20.x'
- 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 all target environments from default_envs
run: |
platformio run
- name: Create GitHub Release
id: 'automatic_releases'
uses: emsesp/action-automatic-releases@v1.0.0
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/*.*

57
.gitignore vendored
View File

@@ -1,45 +1,32 @@
# vscode # vscode
.vscode/c_cpp_properties.json .vscode
.vscode/extensions.json
.vscode/launch.json
.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
# 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 test.sh
*.sln scripts/__pycache__
*.sw? .temp
.pnp.*
*/.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
yarn.lock
analyse.html
interface/vite.config.ts.timestamp*
*.local
# i18n generated files # i18n generated files
interface/src/i18n/i18n-react.tsx interface/src/i18n/i18n-react.tsx
@@ -48,27 +35,11 @@ interface/src/i18n/i18n-util.ts
interface/src/i18n/i18n-util.sync.ts interface/src/i18n/i18n-util.sync.ts
interface/src/i18n/i18n-util.async.ts interface/src/i18n/i18n-util.async.ts
# scripts
test.sh
scripts/run.sh
scripts/__pycache__
scripts/stackdmp.txt
# sonar # sonar
.scannerwork/ .scannerwork/
sonar/ sonar/
bw-output/ build_wrapper_output_directory/
# standalone executable for testing # other build files
emsesp dump_entities.csv
interface/tsconfig.tsbuildinfo dump_entities.xls*
# python virtual environment
venv/
# cspell
words-found-verbose.txt
# sonarlint
compile_commands.json
package.json

View File

@@ -1,6 +0,0 @@
*/node_modules/
build/
dist/*
interface/src/i18n/*
.typesafe-i18n.json

View File

@@ -1,13 +0,0 @@
{
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 85,
"bracketSpacing": true,
"importOrder": ["^react", "^@mui/(.*)$", "^api*/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderGroupNamespaceSpecifiers": true
}

View File

@@ -1,4 +0,0 @@
{
"sonarCloudOrganization": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}

101
.vscode/settings.json vendored
View File

@@ -1,101 +0,0 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.validate": [
"typescript"
],
"eslint.codeActionsOnSave.rules": null,
"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": [
"ini",
"makefile"
],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
"sonarlint.connectedMode.project": {
"connectionId": "emsesp",
"projectKey": "emsesp_EMS-ESP32"
}
}

View File

@@ -5,299 +5,12 @@ 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.7.0] October 27 2024
## **IMPORTANT! BREAKING CHANGES with 3.6.5**
- "ww" and "wwc" has been renamed to "dhw". It is nested JSON object in both the MQTT and API outputs. The old prefix has also been removed from MQTT topics ([#1634](https://github.com/emsesp/EMS-ESP32/issues/1634)). This will impact historical data in home automation systems like Home Assistant and IOBroker. To preserve the current value of dhw energy (was previously nrgww) refer to this issue [#1938](https://github.com/emsesp/EMS-ESP32/issues/1938).
- dhw entities from the MM100/SM100 have been moved under a new Device called 'water'.
- The automatically generated temperature sensor ID has replaced dashes (`-`) with underscores (`_`) to be compatible with Home Assistant.
- `api/system/info` has it's JSON key names changed to camelCase syntax.
For more details go to [www.emsesp.org](https://www.emsesp.org/).
## 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)
- reset history [#1695](https://github.com/emsesp/EMS-ESP32/issues/1695)
- heatpump entities `fan` and `shutdown` [#1690](https://github.com/emsesp/EMS-ESP32/discussions/1690)
- mqtt HA-mode 3 for v3.6 compatible HA entities, set on update v3.6->v3.7
- HP input states [#1723](https://github.com/emsesp/EMS-ESP32/discussions/1723)
- holiday settings for rego 3000 [#1735](https://github.com/emsesp/EMS-ESP32/issues/1735)
- Added scripts for OTA (scripts/upload.py and upload_cli.py) [#1738](https://github.com/emsesp/EMS-ESP32/issues/1738)
- timeout for remote thermostat emulation [#1680](https://github.com/emsesp/EMS-ESP32/discussions/1680), [#1774](https://github.com/emsesp/EMS-ESP32/issues/1774)
- CR120 thermostat as own model() [#1779](https://github.com/emsesp/EMS-ESP32/discussions/1779)
- modules - external linkable module library [#1778](https://github.com/emsesp/EMS-ESP32/issues/1778)
- scheduler onChange and Conditions [#1806](https://github.com/emsesp/EMS-ESP32/issues/1806)
- make remote control timeout editable [#1774](https://github.com/emsesp/EMS-ESP32/issues/1774)
- added extra pump characteristics (mode and pressure for EMS+) by @SLTKA [#1802](https://github.com/emsesp/EMS-ESP32/pull/1802)
- allow device name to be customized [#1174](https://github.com/emsesp/EMS-ESP32/issues/1174)
- Modbus support by @mheyse [#1744](https://github.com/emsesp/EMS-ESP32/issues/1744)
- System Message command [#1854](https://github.com/emsesp/EMS-ESP32/issues/1854)
- scheduler can use web get/post for values and commands [#1806](https://github.com/emsesp/EMS-ESP32/issues/1806)
- RT800 remote emulation [#1867](https://github.com/emsesp/EMS-ESP32/issues/1867)
- RC310 cooling parameters [#1857](https://github.com/emsesp/EMS-ESP32/issues/1857)
- command `api/device/entities` [#1897](https://github.com/emsesp/EMS-ESP32/issues/1897)
- switchprogmode [#1903](https://github.com/emsesp/EMS-ESP32/discussions/1903)
- autodetect and download firmware upgrades via the WebUI
- command 'show log' that lists out the current weblog buffer, showing last messages.
- default web log buffer to 25 lines for ESP32s with no PSRAM
- try and determine correct board profile if none is set during boot
- auto Scroll in WebLog UI - reduced delay so incoming logs are faster
- uploading custom support info, shown to Guest users in Help page [#2054](https://github.com/emsesp/EMS-ESP32/issues/2054)
- feature: Dashboard showing all data (favorites, sensors, custom) [#1958](https://github.com/emsesp/EMS-ESP32/issues/1958)
- entity for low-temperature boilers pump start temp (pumpOnTemp) #2088 [#2088](https://github.com/emsesp/EMS-ESP32/issues/2088)
- internal ESP32 temperature sensor on the S3 [#2077](https://github.com/emsesp/EMS-ESP32/issues/2077)
- MQTT status topic (used in connect and last will) set to Retain [#2086](https://github.com/emsesp/EMS-ESP32/discussions/2086)
- Czech language [2096](https://github.com/emsesp/EMS-ESP32/issues/2096)
- Developer Mode and send EMS Read Commands from WebUI [#2116](https://github.com/emsesp/EMS-ESP32/issues/2116)
- Scheduler functions [#2115](https://github.com/emsesp/EMS-ESP32/issues/2115)
- Set device custom name from telegram 0x01 [#2073](https://github.com/emsesp/EMS-ESP32/issues/2073)
## Fixed
- remote thermostat emulation for RC200 on Rego2000/3000 thermostats [#1691](https://github.com/emsesp/EMS-ESP32/discussions/1691)
- log shows data for F7/F9 requests
- Detection of LittleFS for factory setting wasn't working
- Check for bad GPIOs with Ethernet before the ethernet is initialized
- Show values with factor 50 on webUI [#2064](https://github.com/emsesp/EMS-ESP32/issues/2064)
- Rendering of values between -1 and 0
- Value for 32bit times not-set [#2109](https://github.com/emsesp/EMS-ESP32/issues/2109)
## 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)
- rename DeviceValueTypes, add UINT32 for custom entities
- dynamic register dhw circuits for thermostat
- removed OTA feature [#1738](https://github.com/emsesp/EMS-ESP32/issues/1738)
- added shower min duration [#1801](https://github.com/emsesp/EMS-ESP32/issues/1801)
- Include TXT file along with the generated CSV for Device Data export/download
- thermostat/remotetemp as command [#1835](https://github.com/emsesp/EMS-ESP32/discussions/1835)
- temperaturesensor id notation with underscore [#1794](https://github.com/emsesp/EMS-ESP32/discussions/1794)
- Change key-names in JSON to be compliant and consistent [#1860](https://github.com/emsesp/EMS-ESP32/issues/1860)
- Updates to webUI [#1920](https://github.com/emsesp/EMS-ESP32/issues/1920)
- Correct firmware naming #1933 [#1933](https://github.com/emsesp/EMS-ESP32/issues/1933)
- Don't start Serial console if not connected to a Serial port. Will initiate manually after a CTRL-C/CTRL-S
- WebLog UI matches color schema of the terminal console correctly
- Updated Web libraries, ArduinoJson
- Help page doesn't show detailed tech info if the user is not 'admin' role [#2054](https://github.com/emsesp/EMS-ESP32/issues/2054)
- removed system command `allvalues` and moved to an action called `export`
- Show ems-esp internal devices in device list of system/info
- Scheduler and mqtt run async on systems with psram
- Show IPv6 address type (local/global/ula) in log
## [3.6.5] March 23 2024
## **IMPORTANT! BREAKING CHANGES**
- The Wifi Tx Power setting in Network Settings will be reset to Auto
## 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 # [3.5.0] February 6 2023
## **IMPORTANT! BREAKING CHANGES** ## **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. - 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.
- Support for multiple EMS-ESPs [#759] has been added as an optional setting for MQTT. When enabled, which is now the default, all MQTT Discovery Entity IDs will include the MQTT base name and the shortname of the EMS-ESP device entity. For example what was previously `sensor.boiler_actual_boiler_temperature` will now become `sensor.ems_esp_boiler_boiltemp`. If you still want to use the old format and retain the history and script compatibility in Home Assistant then set this back to the old format.
## Added ## Added
@@ -373,7 +86,7 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
- fix Table resizing in WebUI [#519](https://github.com/emsesp/EMS-ESP32/issues/519) - 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) - 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) - losing entitiy wwcomfort [#581](https://github.com/emsesp/EMS-ESP32/issues/581)
## Changed ## Changed
@@ -400,7 +113,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
@@ -500,7 +213,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)
@@ -722,4 +435,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 +1,20 @@
# Changelog # Changelog
# [3.5.1]
## Added
- Detect old Tado thermostat, device-id 0x19, no entities
- Some more HM200 entities [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
- Add entity to force heating off (for systems without thermostat) [#951](https://github.com/emsesp/EMS-ESP32/issues/951)
## Fixed
- HA-discovery for analog sensor commands [#1035](https://github.com/emsesp/EMS-ESP32/issues/1035)
## Changed
- Use byte 0 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

View File

@@ -6,7 +6,7 @@ Everybody is welcome and invited to contribute to the EMS-ESP Project by:
- providing Pull Requests (Features, Fixes, suggestions) - providing Pull Requests (Features, Fixes, suggestions)
- testing new released features and report issues on your EMS equipment - testing new released features and report issues on your EMS equipment
- contributing to missing [documentation](https://emsesp.org) - contributing to missing [documentation](https://emsesp.github.io/docs)
This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs. This document describes rules that are in effect for this repository, meant for handling issues by contributors in the issue tracker and PRs.

View File

@@ -1,11 +1,9 @@
# #
# GNUMakefile for EMS-ESP # GNUMakefile for EMS-ESP
# This is mainly used to generate the .o files for SonarQube analysis
# #
# NUMJOBS=${NUMJOBS:-" -j10 "} NUMJOBS=${NUMJOBS:-" -j4 "}
# MAKEFLAGS+="j " MAKEFLAGS+="j "
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Project Structure # Project Structure
#---------------------------------------------------------------------- #----------------------------------------------------------------------
@@ -19,8 +17,8 @@
#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 lib/semver
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/semver lib/* src/devices
LIBRARIES := LIBRARIES :=
CPPCHECK = cppcheck CPPCHECK = cppcheck
@@ -30,20 +28,19 @@ CHECKFLAGS = -q --force --std=c++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Languages Standard # Languages Standard
#---------------------------------------------------------------------- #----------------------------------------------------------------------
C_STANDARD := -std=c17 # C_STANDARD := -std=c17
CXX_STANDARD := -std=gnu++14 # CXX_STANDARD := -std=c++17
C_STANDARD := -std=c11
# C_STANDARD := -std=c11 CXX_STANDARD := -std=c++11
# CXX_STANDARD := -std=c++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Defined Symbols # Defined Symbols
#---------------------------------------------------------------------- #----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 DEFINES += -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500 DEFINES += -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_USE_SERIAL
DEFINES += $(ARGS) DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.0-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\" DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.5.0b11\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Sources & Files # Sources & Files
@@ -82,8 +79,10 @@ CPPFLAGS += -g3
CPPFLAGS += -Os CPPFLAGS += -Os
CFLAGS += $(CPPFLAGS) CFLAGS += $(CPPFLAGS)
CFLAGS += -Wall -Wextra -Werror -Wswitch-enum CFLAGS += -Wall
CFLAGS += -Wno-tautological-constant-out-of-range-compare -Wno-unused-parameter -Wno-inconsistent-missing-override -Wno-missing-braces -Wno-unused-lambda-capture -Wno-sign-compare CFLAGS += -Wextra
CFLAGS += -Wno-unused-parameter
CXXFLAGS += $(CFLAGS) -MMD CXXFLAGS += $(CFLAGS) -MMD
#---------------------------------------------------------------------- #----------------------------------------------------------------------

View File

@@ -4,7 +4,7 @@
[![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)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=emsesp_EMS-ESP32&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=emsesp_EMS-ESP32&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://app.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/9441142f49424ef891e8f5251866ee6b)](https://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&utm_medium=referral&utm_content=emsesp/EMS-ESP32&utm_campaign=Badge_Grade)
[![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases) [![downloads](https://img.shields.io/github/downloads/emsesp/EMS-ESP32/total.svg)](https://github.com/emsesp/EMS-ESP32/releases)
[![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)
@@ -12,60 +12,41 @@
[![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 to communicate with **EMS** (Energy Management System) compatible equipment from manufacturers such as Bosch, Buderus, Nefit, Junkers, Worcester, Sieger, elm.leblanc and iVT. **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.
It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built. ## **Features**
## **Key Features** - A multi-user, multi-language secure web interface to change settings and monitor incoming data
- A console, accessible via Serial and Telnet for more advanced monitoring
- Compatible with EMS, EMS+, EMS2, EMS Plus, Logamatic EMS, Junkers 2-wire, Heatronic 3 and 4 - Native support for Home Assistant, Domoticz and openHAB via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
- Supporting over 120 different EMS compatible devices such as thermostats, boilers, heat pumps, mixing units, solar modules, connect modules, ventilation units, switches and more - Can run standalone as an independent WiFi Access Point or join an existing WiFi network
- Easy to add external Temperature and Analog sensors that are attached to GPIO pins on the ESP32 board - Easy first-time configuration via a web Captive Portal
- A multi-user, multi-language web interface to change settings and monitor incoming data - Support for more than [110 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways, switches, heat sources)
- A simple to use console, accessible via Serial/USB or Telnet for advanced operations and detailed monitoring
- Native integration with Home Assistant, Domoticz, openHAB and Modbus
- Easy setup and install with automatic updates
- Simulation of remote thermostats
- Localized in 11 languages, and customizable to rename any device or sensor
- Extendable by adding own custom EMS entities
- Expandable via adding user-built external modules
- A powerful Scheduler to automate tasks and trigger events based data changes
- A Notification service to alert you of important events
## **Installing**
For a quick install of the latest stable release go to [https://install.emsesp.org](https://install.emsesp.org). For other methods of installing and upgrading, and switching over to the development version go to [this section](https://emsesp.org/Getting-Started/#first-time-install) in the documentation.
If you're upgrading a BBQKees Electronics EMS Gateway and unsure which firmware to use, please refer to the [this overview](https://emsesp.org/Getting-Started/#bbqkees-electronics-ems-gateway).
## **Documentation** ## **Documentation**
Visit [emsesp.org](https://emsesp.org) for more details on how to install and configure EMS-ESP. There is also a collection of Frequently Asked Questions and Troubleshooting tips with example customizations from the community. For the complete documentation on how to install, configure and get support visit the [EMS-ESP Wiki](https://emsesp.github.io/docs).
## **Getting Support** ## **Support**
To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT). To chat with the community reach out on our [Discord Server](https://discord.gg/3J3GgnzpyT).
If you find an issue or have a request, see [here](https://emsesp.org/Support/) on how to submit a bug report or feature request. If you like **EMS-ESP**, please give it a star, or fork it and contribute or offer a small donation!
## **Live Demo** ## **Demo**
For a live demo go to [demo.emsesp.org](https://demo.emsesp.org). Pick a language from the sign on page and log in with any username or password. Note not all features are operational as it's based on static data. For a live demo of the Web UI click [here](https://ems-esp.derbyshire.nl) and log in with any username/password.
## **Contributors** ## **Contributors**
EMS-ESP is a project created by [proddy](https://github.com/proddy) and owned and maintained by both [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP) with support from [BBQKees Electronics](https://bbqkees-electronics.nl). EMS-ESP is a project owned and maintained by [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP).
You can contact us using [this form](https://emsesp.org/Contact/).
If you like **EMS-ESP**, please give it a ✨ on GitHub, or even better fork it and contribute. You can also offer a small donation. This is an open-source project maintained by volunteers, and your support is greatly appreciated.
## **Libraries used** ## **Libraries used**
- [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified - [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 awesome open source libraries - [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 processing - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client - [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 - ESPAsyncWebServer and AsyncTCP for the Web server and TCP backends, with custom modifications for performance
## **License** ## **License**
@@ -78,14 +59,14 @@ This program is licensed under GPL-3.0
| | | | | |
| ---------------------------------- | -------------------------------- | | ---------------------------------- | -------------------------------- |
| ![Web Settings](media/web_settings.png) | ![Web Status](media/web_status.png) | | <img src="media/web_settings.png"> | <img src="media/web_status.png"> |
| ![Web Devices](media/web_devices.png) | ![Web MQTT](media/web_mqtt.png) | | <img src="media/web_devices.png"> | <img src="media/web_mqtt.png"> |
| ![Web Edit](media/web_edit.png) | ![Web Log](media/web_log.png) | | <img src="media/web_edit.png"> | <img src="media/web_log.png"> |
### Telnet Console ### Telnet Console
![Console](media/console0.png) <img src="media/console0.png" width=80% height=80%>
### Home Assistant ### In Home Assistant
![Home Assistant](media/ha_lovelace.png) <img src="media/ha_lovelace.png" width=80% height=80%>

5
RELEASENOTES.md Normal file
View File

@@ -0,0 +1,5 @@
# ![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.

7
RELEASENOTES_DEV.md Normal file
View File

@@ -0,0 +1,7 @@
# ![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.

View File

@@ -1,13 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"dictionaryDefinitions": [
{
"name": "project-words",
"path": "./project-words.txt",
"addWords": true
}
],
"dictionaries": ["project-words"],
"ignorePaths": ["node_modules", "compile_commands.json", "WWWData.h", "**/venv/**", "lib/eModbus", "lib/ESPAsyncWebServer", "lib/espMqttClient", "analyse.html", "dist", "**/*.csv", "locale_translations.h", "TZ.tsx", "**/*.txt","build/**", "**/i18n/**", "/project-words.txt"]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,212 +0,0 @@
telegram_type_id,name,is_fetched
0x04,UBAFactory,fetched
0x06,RCTime,
0x0A,EasyMonitor,fetched
0x10,UBAErrorMessage1,
0x11,UBAErrorMessage2,
0x12,RCErrorMessage,
0x13,RCErrorMessage2,
0x14,UBATotalUptime,fetched
0x15,UBAMaintenanceData,
0x16,UBAParameters,fetched
0x18,UBAMonitorFast,
0x19,UBAMonitorSlow,
0x1A,UBASetPoints,
0x1C,UBAMaintenanceStatus,
0x1E,WM10TempMessage,
0x23,JunkersSetMixer,fetched
0x26,UBASettingsWW,fetched
0x28,WeatherComp,fetched
0x2A,MC110Status,
0x2E,Meters,
0x33,UBAParameterWW,fetched
0x34,UBAMonitorWW,
0x35,UBAFlags,
0x37,WWSettings,fetched
0x38,WWTimer,fetched
0x39,WWCircTimer,fetched
0x3A,RC30WWSettings,fetched
0x3B,Energy,
0x3D,RC35Set,
0x3E,RC35Monitor,
0x3F,RC35Timer,
0x40,RC30Temp,
0x41,RC30Monitor,
0x42,RC35Timer2,
0x47,RC35Set,
0x48,RC35Monitor,
0x49,RC35Timer,
0x4C,RC35Timer2,
0x51,RC35Set,
0x52,RC35Monitor,
0x53,RC35Timer,
0x56,RC35Timer2,
0x5B,RC35Set,
0x5C,RC35Monitor,
0x5D,RC35Timer,
0x60,RC35Timer2,
0x96,SM10Config,fetched
0x97,SM10Monitor,
0x9C,WM10MonitorMessage,
0x9D,WM10SetMessage,
0xA2,RCError,
0xA3,RCOutdoorTemp,
0xA5,IBASettings,fetched
0xA7,RC30Set,
0xA9,RC30Vacation,fetched
0xAA,MMConfigMessage,fetched
0xAB,MMStatusMessage,
0xAC,MMSetMessage,
0xAF,RC20Remote,
0xB0,RC10Set,
0xB1,RC10Monitor,
0xBB,HybridSettings,fetched
0xBF,ErrorMessage,
0xC2,UBAErrorMessage3,
0xD1,UBAOutdoorTemp,
0xE3,UBAMonitorSlowPlus2,
0xE4,UBAMonitorFastPlus,
0xE5,UBAMonitorSlowPlus,
0xE6,UBAParametersPlus,fetched
0xE9,UBAMonitorWWPlus,
0xEA,UBAParameterWWPlus,fetched
0x0101,ISM1Set,fetched
0x0103,ISM1StatusMessage,fetched
0x0104,ISM2StatusMessage,
0x010C,IPMStatusMessage,
0x011E,IPMTempMessage,
0x0165,JunkersSet,
0x0166,JunkersSet,
0x0167,JunkersSet,
0x0168,JunkersSet,
0x016F,JunkersMonitor,
0x0170,JunkersMonitor,
0x0171,JunkersMonitor,
0x0172,JunkersMonitor,
0x0179,JunkersSet,
0x017A,JunkersSet,
0x017B,JunkersSet,
0x017C,JunkersSet,
0x01D3,JunkersDhw,fetched
0x023A,RC300OutdoorTemp,fetched
0x023E,PVSettings,fetched
0x0240,RC300Settings,fetched
0x0267,RC300Floordry,
0x0269,RC300Holiday1,fetched
0x0291,HPMode,fetched
0x0292,HPMode,fetched
0x0293,HPMode,fetched
0x0294,HPMode,fetched
0x029B,RC300Curves,
0x029C,RC300Curves,
0x029D,RC300Curves,
0x029E,RC300Curves,
0x029F,RC300Curves,
0x02A0,RC300Curves,
0x02A1,RC300Curves,
0x02A2,RC300Curves,
0x02A5,RC300Monitor,
0x02A6,RC300Monitor,
0x02A7,RC300Monitor,
0x02A8,RC300Monitor,
0x02A9,RC300Monitor,
0x02AA,RC300Monitor,
0x02AB,RC300Monitor,
0x02AC,RC300Monitor,
0x02AF,RC300Summer,
0x02B0,RC300Summer,
0x02B1,RC300Summer,
0x02B2,RC300Summer,
0x02B3,RC300Summer,
0x02B4,RC300Summer,
0x02B5,RC300Summer,
0x02B6,RC300Summer,
0x02B9,RC300Set,
0x02BA,RC300Set,
0x02BB,RC300Set,
0x02BC,RC300Set,
0x02BD,RC300Set,
0x02BE,RC300Set,
0x02BF,RC300Set,
0x02C0,RC300Set,
0x02CC,RC300Set2,
0x02CD,MMPLUSConfigMessage,fetched
0x02CE,RC300Set2,
0x02D0,RC300Set2,
0x02D2,RC300Set2,
0x02D5,MMPLUSConfigMessage,fetched
0x02D6,HPPump2,fetched
0x02D7,MMPLUSStatusMessage,
0x02DF,MMPLUSStatusMessage,
0x02F5,RC300WWmode,fetched
0x02F6,RC300WW2mode,fetched
0x031B,RC300WWtemp,fetched
0x031D,RC300WWmode2,
0x031E,RC300WWmode2,
0x0358,SM100SystemConfig,fetched
0x035A,SM100CircuitConfig,fetched
0x035C,SM100HeatAssist,fetched
0x035D,SM100Circuit2Config,fetched
0x035F,SM100Config1,fetched
0x0361,SM100Differential,fetched
0x0362,SM100Monitor,
0x0363,SM100Monitor2,
0x0364,SM100Status,
0x0366,SM100Config,
0x036A,SM100Status2,
0x0380,SM100CollectorConfig,fetched
0x038E,SM100Energy,fetched
0x0391,SM100Time,fetched
0x0467,HPSet,
0x0468,HPSet,
0x0469,HPSet,
0x046A,HPSet,
0x0471,RC300Summer2,
0x0472,RC300Summer2,
0x0473,RC300Summer2,
0x0474,RC300Summer2,
0x0475,RC300Summer2,
0x0476,RC300Summer2,
0x0477,RC300Summer2,
0x0478,RC300Summer2,
0x047B,HP2,
0x0484,HPSilentMode,fetched
0x0485,HpCooling,fetched
0x0486,HpInConfig,fetched
0x0488,HPValve,fetched
0x048A,HpPool,fetched
0x048B,HPPumps,fetched
0x048D,HpPower,fetched
0x048F,HpTemperatures,
0x0491,HPAdditionalHeater,fetched
0x0492,HpHeaterConfig,fetched
0x0494,UBAEnergySupplied,
0x0495,UBAInformation,
0x0499,HPDhwSettings,fetched
0x049C,HPSettings2,fetched
0x049D,HPSettings3,fetched
0x04A2,HpInput,fetched
0x04A5,HPFan,fetched
0x04A7,HPPowerLimit,fetched
0x04AA,HPPower2,fetched
0x04AE,HPEnergy,fetched
0x04AF,HPMeters,fetched
0x056B,VentilationMode,fetched
0x0583,VentilationMonitor,
0x0585,Blowerspeed,
0x0587,Bypass,
0x05BA,HpPoolStatus,fetched
0x05D9,Airquality,
0x0772,HIUSettings,
0x0779,HIUMonitor,
0x0935,EM100SetMessage,fetched
0x0936,EM100OutMessage,
0x0937,EM100TempMessage,
0x0938,EM100InputMessage,
0x0939,EM100MonitorMessage,
0x093A,EM100ConfigMessage,
0x0998,HPSettings,fetched
0x0999,HPFunctionTest,fetched
0x099B,HPFlowTemp,
0x099C,HPComp,
0x09A0,HPTemperature,
1 telegram_type_id name is_fetched
2 0x04 UBAFactory fetched
3 0x06 RCTime
4 0x0A EasyMonitor fetched
5 0x10 UBAErrorMessage1
6 0x11 UBAErrorMessage2
7 0x12 RCErrorMessage
8 0x13 RCErrorMessage2
9 0x14 UBATotalUptime fetched
10 0x15 UBAMaintenanceData
11 0x16 UBAParameters fetched
12 0x18 UBAMonitorFast
13 0x19 UBAMonitorSlow
14 0x1A UBASetPoints
15 0x1C UBAMaintenanceStatus
16 0x1E WM10TempMessage
17 0x23 JunkersSetMixer fetched
18 0x26 UBASettingsWW fetched
19 0x28 WeatherComp fetched
20 0x2A MC110Status
21 0x2E Meters
22 0x33 UBAParameterWW fetched
23 0x34 UBAMonitorWW
24 0x35 UBAFlags
25 0x37 WWSettings fetched
26 0x38 WWTimer fetched
27 0x39 WWCircTimer fetched
28 0x3A RC30WWSettings fetched
29 0x3B Energy
30 0x3D RC35Set
31 0x3E RC35Monitor
32 0x3F RC35Timer
33 0x40 RC30Temp
34 0x41 RC30Monitor
35 0x42 RC35Timer2
36 0x47 RC35Set
37 0x48 RC35Monitor
38 0x49 RC35Timer
39 0x4C RC35Timer2
40 0x51 RC35Set
41 0x52 RC35Monitor
42 0x53 RC35Timer
43 0x56 RC35Timer2
44 0x5B RC35Set
45 0x5C RC35Monitor
46 0x5D RC35Timer
47 0x60 RC35Timer2
48 0x96 SM10Config fetched
49 0x97 SM10Monitor
50 0x9C WM10MonitorMessage
51 0x9D WM10SetMessage
52 0xA2 RCError
53 0xA3 RCOutdoorTemp
54 0xA5 IBASettings fetched
55 0xA7 RC30Set
56 0xA9 RC30Vacation fetched
57 0xAA MMConfigMessage fetched
58 0xAB MMStatusMessage
59 0xAC MMSetMessage
60 0xAF RC20Remote
61 0xB0 RC10Set
62 0xB1 RC10Monitor
63 0xBB HybridSettings fetched
64 0xBF ErrorMessage
65 0xC2 UBAErrorMessage3
66 0xD1 UBAOutdoorTemp
67 0xE3 UBAMonitorSlowPlus2
68 0xE4 UBAMonitorFastPlus
69 0xE5 UBAMonitorSlowPlus
70 0xE6 UBAParametersPlus fetched
71 0xE9 UBAMonitorWWPlus
72 0xEA UBAParameterWWPlus fetched
73 0x0101 ISM1Set fetched
74 0x0103 ISM1StatusMessage fetched
75 0x0104 ISM2StatusMessage
76 0x010C IPMStatusMessage
77 0x011E IPMTempMessage
78 0x0165 JunkersSet
79 0x0166 JunkersSet
80 0x0167 JunkersSet
81 0x0168 JunkersSet
82 0x016F JunkersMonitor
83 0x0170 JunkersMonitor
84 0x0171 JunkersMonitor
85 0x0172 JunkersMonitor
86 0x0179 JunkersSet
87 0x017A JunkersSet
88 0x017B JunkersSet
89 0x017C JunkersSet
90 0x01D3 JunkersDhw fetched
91 0x023A RC300OutdoorTemp fetched
92 0x023E PVSettings fetched
93 0x0240 RC300Settings fetched
94 0x0267 RC300Floordry
95 0x0269 RC300Holiday1 fetched
96 0x0291 HPMode fetched
97 0x0292 HPMode fetched
98 0x0293 HPMode fetched
99 0x0294 HPMode fetched
100 0x029B RC300Curves
101 0x029C RC300Curves
102 0x029D RC300Curves
103 0x029E RC300Curves
104 0x029F RC300Curves
105 0x02A0 RC300Curves
106 0x02A1 RC300Curves
107 0x02A2 RC300Curves
108 0x02A5 RC300Monitor
109 0x02A6 RC300Monitor
110 0x02A7 RC300Monitor
111 0x02A8 RC300Monitor
112 0x02A9 RC300Monitor
113 0x02AA RC300Monitor
114 0x02AB RC300Monitor
115 0x02AC RC300Monitor
116 0x02AF RC300Summer
117 0x02B0 RC300Summer
118 0x02B1 RC300Summer
119 0x02B2 RC300Summer
120 0x02B3 RC300Summer
121 0x02B4 RC300Summer
122 0x02B5 RC300Summer
123 0x02B6 RC300Summer
124 0x02B9 RC300Set
125 0x02BA RC300Set
126 0x02BB RC300Set
127 0x02BC RC300Set
128 0x02BD RC300Set
129 0x02BE RC300Set
130 0x02BF RC300Set
131 0x02C0 RC300Set
132 0x02CC RC300Set2
133 0x02CD MMPLUSConfigMessage fetched
134 0x02CE RC300Set2
135 0x02D0 RC300Set2
136 0x02D2 RC300Set2
137 0x02D5 MMPLUSConfigMessage fetched
138 0x02D6 HPPump2 fetched
139 0x02D7 MMPLUSStatusMessage
140 0x02DF MMPLUSStatusMessage
141 0x02F5 RC300WWmode fetched
142 0x02F6 RC300WW2mode fetched
143 0x031B RC300WWtemp fetched
144 0x031D RC300WWmode2
145 0x031E RC300WWmode2
146 0x0358 SM100SystemConfig fetched
147 0x035A SM100CircuitConfig fetched
148 0x035C SM100HeatAssist fetched
149 0x035D SM100Circuit2Config fetched
150 0x035F SM100Config1 fetched
151 0x0361 SM100Differential fetched
152 0x0362 SM100Monitor
153 0x0363 SM100Monitor2
154 0x0364 SM100Status
155 0x0366 SM100Config
156 0x036A SM100Status2
157 0x0380 SM100CollectorConfig fetched
158 0x038E SM100Energy fetched
159 0x0391 SM100Time fetched
160 0x0467 HPSet
161 0x0468 HPSet
162 0x0469 HPSet
163 0x046A HPSet
164 0x0471 RC300Summer2
165 0x0472 RC300Summer2
166 0x0473 RC300Summer2
167 0x0474 RC300Summer2
168 0x0475 RC300Summer2
169 0x0476 RC300Summer2
170 0x0477 RC300Summer2
171 0x0478 RC300Summer2
172 0x047B HP2
173 0x0484 HPSilentMode fetched
174 0x0485 HpCooling fetched
175 0x0486 HpInConfig fetched
176 0x0488 HPValve fetched
177 0x048A HpPool fetched
178 0x048B HPPumps fetched
179 0x048D HpPower fetched
180 0x048F HpTemperatures
181 0x0491 HPAdditionalHeater fetched
182 0x0492 HpHeaterConfig fetched
183 0x0494 UBAEnergySupplied
184 0x0495 UBAInformation
185 0x0499 HPDhwSettings fetched
186 0x049C HPSettings2 fetched
187 0x049D HPSettings3 fetched
188 0x04A2 HpInput fetched
189 0x04A5 HPFan fetched
190 0x04A7 HPPowerLimit fetched
191 0x04AA HPPower2 fetched
192 0x04AE HPEnergy fetched
193 0x04AF HPMeters fetched
194 0x056B VentilationMode fetched
195 0x0583 VentilationMonitor
196 0x0585 Blowerspeed
197 0x0587 Bypass
198 0x05BA HpPoolStatus fetched
199 0x05D9 Airquality
200 0x0772 HIUSettings
201 0x0779 HIUMonitor
202 0x0935 EM100SetMessage fetched
203 0x0936 EM100OutMessage
204 0x0937 EM100TempMessage
205 0x0938 EM100InputMessage
206 0x0939 EM100MonitorMessage
207 0x093A EM100ConfigMessage
208 0x0998 HPSettings fetched
209 0x0999 HPFunctionTest fetched
210 0x099B HPFlowTemp
211 0x099C HPComp
212 0x09A0 HPTemperature

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 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags # Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x005000, nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, , 0x002000, otadata, data, ota, , 0x2000,
boot, app, factory, , 0x480000, app0, app, ota_0, , 0x7F0000,
app0, app, ota_0, , 0x490000, app1, app, ota_1, , 0x7F0000,
app1, app, ota_1, , 0x490000, spiffs, data, spiffs, , 64K,
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 0x5000
3 otadata data ota 0x002000 0x2000
4 boot app0 app factory ota_0 0x480000 0x7F0000
5 app0 app1 app ota_0 ota_1 0x490000 0x7F0000
6 app1 spiffs app data ota_1 spiffs 0x490000 64K
nvs1 data nvs 0x040000
spiffs data spiffs 0x200000
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

@@ -25,12 +25,18 @@ build_flags =
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\" -D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
-D FACTORY_NTP_SERVER=\"time.google.com\" -D FACTORY_NTP_SERVER=\"time.google.com\"
; OTA settings
-D FACTORY_OTA_PORT=8266
-D FACTORY_OTA_PASSWORD=\"ems-esp-neo\"
-D FACTORY_OTA_ENABLED=false
; MQTT settings ; MQTT settings
-D FACTORY_MQTT_ENABLED=false -D FACTORY_MQTT_ENABLED=false
-D FACTORY_MQTT_HOST=\"\" -D FACTORY_MQTT_HOST=\"\"
-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,4 +0,0 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

View File

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

6
interface/.prettierrc Normal file
View File

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

View File

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

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

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,44 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
prettierConfig,
{
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname
}
}
},
{
ignores: [
'dist/*',
'*.mjs',
'build/*',
'*.js',
'**/*.cjs',
'**/unpack.ts',
'i18n*.*'
]
},
{
rules: {
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: false
}
]
}
}
);

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>

31267
interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,104 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.7.0", "version": "3.5.0",
"description": "EMS-ESP WebUI",
"homepage": "https://emsesp.org",
"author": "proddy, emsesp.org",
"license": "MIT",
"private": true, "private": true,
"type": "module", "proxy": "http://localhost:3080",
"scripts": {
"dev": "vite dev",
"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-rest\" \"vite preview\"",
"mock-rest": "bun --watch ../mock-api/rest_server.ts",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-rest\" \"vite\"",
"typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix"
},
"dependencies": { "dependencies": {
"@alova/adapter-xhr": "2.0.9", "@emotion/react": "^11.10.6",
"@emotion/react": "^11.13.3", "@emotion/styled": "^11.10.6",
"@emotion/styled": "^11.13.0", "@msgpack/msgpack": "^2.8.0",
"@mui/icons-material": "^6.1.5", "@mui/icons-material": "^5.11.9",
"@mui/material": "^6.1.5", "@mui/material": "^5.11.10",
"@table-library/react-table-library": "4.1.7", "@table-library/react-table-library": "4.0.26",
"alova": "3.1.1", "@types/lodash": "^4.14.191",
"@types/node": "^18.14.0",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-router-dom": "^5.3.3",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"jwt-decode": "^4.0.0", "axios": "^1.3.4",
"mime-types": "^2.1.35", "jwt-decode": "^3.1.2",
"preact": "^10.24.3", "lodash": "^4.17.21",
"react": "^18.3.1", "notistack": "^2.0.8",
"react-dom": "^18.3.1", "react": "^18.2.0",
"react-icons": "^5.3.0", "react-app-rewired": "^2.2.1",
"react-router-dom": "^6.27.0", "react-dom": "^18.2.0",
"react-toastify": "^10.0.6", "react-dropzone": "^14.2.3",
"typesafe-i18n": "^5.26.2", "react-icons": "^4.7.1",
"typescript": "^5.6.3" "react-router-dom": "^6.8.1",
"react-scripts": "5.0.1",
"sockette": "^2.0.6",
"typesafe-i18n": "^5.24.1",
"typescript": "^4.9.5"
},
"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 typesafe-i18n mock-api",
"lint": "eslint . --ext .ts,.tsx",
"typesafe-i18n": "typesafe-i18n"
},
"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": 220
}
],
"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": {
"@babel/core": "^7.26.0", "nodemon": "^2.0.20",
"@eslint/js": "^9.13.0", "npm-run-all": "^4.1.5",
"@preact/compat": "^18.3.1", "http-proxy-middleware": "^2.0.6"
"@preact/preset-vite": "^2.9.1", }
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/formidable": "^3",
"@types/node": "^22.8.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-router-dom": "^5.3.3",
"concurrently": "^9.0.1",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"formidable": "^3.5.2",
"prettier": "^3.3.3",
"rollup-plugin-visualizer": "^5.12.0",
"terser": "^5.36.0",
"typescript-eslint": "8.11.0",
"vite": "^5.4.10",
"vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.0.1"
},
"packageManager": "yarn@4.5.1"
} }

View File

@@ -1,33 +1,9 @@
import crypto from 'crypto'; const { resolve, relative, sep } = require('path');
import { const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
createWriteStream, var zlib = require('zlib');
existsSync, var mime = require('mime-types');
readFileSync,
readdirSync,
unlinkSync
} from 'fs';
import mime from 'mime-types';
import { relative, resolve, sep } from 'path';
import zlib from 'zlib';
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) => {
@@ -41,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);
@@ -48,68 +28,94 @@ function cleanAndOpen(path) {
return createWriteStream(path, { flags: 'w+' }); return createWriteStream(path, { flags: 'w+' });
} }
const writeFile = (relativeFilePath, buffer) => { class ProgmemGenerator {
constructor(options = {}) {
const { outputPath, bytesPerLine = 20, indent = ' ', includes = ARDUINO_INCLUDES } = options;
this.options = { outputPath, bytesPerLine, indent, includes };
}
apply(compiler) {
compiler.hooks.emit.tapAsync({ name: 'ProgmemGenerator' }, (compilation, callback) => {
const { outputPath, bytesPerLine, indent, includes } = this.options;
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
try {
const writeIncludes = () => {
writeStream.write(includes);
};
const writeFile = (relativeFilePath, buffer) => {
const variable = 'ESP_REACT_DATA_' + fileInfo.length; const 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)'); const writeFiles = () => {
totalSize += size; // process static files
}; const buildPath = compilation.options.output.path;
for (const filePath of getFilesSync(buildPath)) {
// start
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
const buildPath = resolve(sourcePath);
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()));
});
};
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());
};
writeIncludes();
writeFiles();
writeWWWClass();
writeStream.on('finish', () => {
callback();
});
} finally {
writeStream.end();
}
});
}
} }
// add class module.exports = ProgmemGenerator;
writeStream.write(generateWWWClass());
// end
writeStream.end();
console.log('Total size: ' + totalSize / 1000 + ' KB');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -1,20 +1,26 @@
/* /*
* Uses font-weight 400 (normal) only, no bold, and Latin with a few extra unicode chars. * Just supporting latin due to size constrains on the esp chip
* This is to keep flash memory to a minimum *
* View fonts on https://fonts.google.com/ * The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
* Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto *
* 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+0104-0107, U+0118-0119, U+0131, U+0141-0144, U+0152-0153, U+015A-015B, U+0179-017C,
local('Roboto'), 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,
local('Roboto-Regular'), U+FFFD;
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, U+015E-015F, U+0179-017C, U+02BB-02BC, @font-face {
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+0104-0107, U+0118-0119, U+0131, U+0141-0144, U+0152-0153, U+015A-015B, 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, 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,45 +1,61 @@
import { useEffect, useState } from 'react'; import { FC, createRef, createContext, useContext, useEffect, useState, RefObject } from 'react';
import { Slide, ToastContainer } 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 { FeaturesLoader } from './contexts/features';
import CustomTheme from './CustomTheme';
import AppRouting from './AppRouting';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme';
import TypesafeI18n from 'i18n/i18n-react';
import { detectLocale } from 'i18n/i18n-util';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { localStorageDetector } from 'typesafe-i18n/detectors'; import { localStorageDetector } from 'typesafe-i18n/detectors';
import TypesafeI18n from './i18n/i18n-react';
import { detectLocale } from './i18n/i18n-util';
import { loadLocaleAsync } from './i18n/i18n-util.async';
const detectedLocale = detectLocale(localStorageDetector); const detectedLocale = detectLocale(localStorageDetector);
const App = () => { const App: FC = () => {
const notistackRef: RefObject<any> = createRef();
const onClickDismiss = (key: string | number | undefined) => () => {
notistackRef.current.closeSnackbar(key);
};
const ColorModeContext = createContext({ toggleColorMode: () => {} });
const colorMode = useContext(ColorModeContext);
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
useEffect(() => { useEffect(() => {
void loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true)); loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
}, []); }, []);
if (!wasLoaded) return null; if (!wasLoaded) return null;
return ( return (
<ColorModeContext.Provider value={colorMode}>
<TypesafeI18n locale={detectedLocale}> <TypesafeI18n locale={detectedLocale}>
<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-left" </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> </TypesafeI18n>
</ColorModeContext.Provider>
); );
}; };

View File

@@ -1,41 +1,57 @@
import { useContext, useEffect } from 'react'; import { FC, useContext, useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
import { toast } from 'react-toastify'; import { useSnackbar, VariantType } from 'notistack';
import AuthenticatedRouting from 'AuthenticatedRouting'; import { useI18nContext } from './i18n/i18n-react';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components'; import { Authentication, AuthenticationContext } from './contexts/authentication';
import { Authentication, AuthenticationContext } from 'contexts/authentication'; import { FeaturesContext } from './contexts/features';
import { useI18nContext } from 'i18n/i18n-react'; import { RequireAuthenticated, RequireUnauthenticated } from './components';
import SignIn from './SignIn';
import AuthenticatedRouting from './AuthenticatedRouting';
interface SecurityRedirectProps { interface SecurityRedirectProps {
message: string; message: string;
variant?: VariantType;
signOut?: boolean; signOut?: boolean;
} }
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => { 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="/" />;
}; };
const AppRouting = () => { export const RemoveTrailingSlashes = () => {
const location = useLocation();
return (
location.pathname.match('/.*/$') && (
<Navigate
to={{
pathname: location.pathname.replace(/\/+$/, ''),
search: location.search
}}
/>
)
);
};
const AppRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
return ( return (
<Authentication> <Authentication>
<RemoveTrailingSlashes />
<Routes> <Routes>
<Route <Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} />
path="/unauthorized" <Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} variant="success" />} />
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} {features.security && (
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route <Route
path="/" path="/"
element={ element={
@@ -44,6 +60,7 @@ const AppRouting = () => {
</RequireUnauthenticated> </RequireUnauthenticated>
} }
/> />
)}
<Route <Route
path="/*" path="/*"
element={ element={

View File

@@ -1,73 +1,63 @@
import { useContext } from 'react'; import { FC, useCallback, useContext, useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { AxiosError } from 'axios';
import CustomEntities from 'app/main/CustomEntities'; import { FeaturesContext } from './contexts/features';
import Customizations from 'app/main/Customizations'; import * as AuthenticationApi from './api/authentication';
import Dashboard from 'app/main/Dashboard'; import { PROJECT_PATH } from './api/env';
import Devices from 'app/main/Devices'; import { AXIOS } from './api/endpoints';
import Help from 'app/main/Help'; import { Layout, RequireAdmin } from './components';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler'; import ProjectRouting from './project/ProjectRouting';
import Sensors from 'app/main/Sensors';
import APSettings from 'app/settings/APSettings'; import NetworkConnection from './framework/network/NetworkConnection';
import ApplicationSettings from 'app/settings/ApplicationSettings'; import AccessPoint from './framework/ap/AccessPoint';
import DownloadUpload from 'app/settings/DownloadUpload'; import NetworkTime from './framework/ntp/NetworkTime';
import MqttSettings from 'app/settings/MqttSettings'; import Mqtt from './framework/mqtt/Mqtt';
import NTPSettings from 'app/settings/NTPSettings'; import System from './framework/system/System';
import Settings from 'app/settings/Settings'; import Security from './framework/security/Security';
import Version from 'app/settings/Version';
import Network from 'app/settings/network/Network'; const AuthenticatedRouting: FC = () => {
import Security from 'app/settings/security/Security'; const { features } = useContext(FeaturesContext);
import APStatus from 'app/status/APStatus'; const location = useLocation();
import Activity from 'app/status/Activity'; const navigate = useNavigate();
import HardwareStatus from 'app/status/HardwareStatus';
import MqttStatus from 'app/status/MqttStatus'; const handleApiResponseError = useCallback(
import NTPStatus from 'app/status/NTPStatus'; (error: AxiosError) => {
import NetworkStatus from 'app/status/NetworkStatus'; if (error.response && error.response.status === 401) {
import Status from 'app/status/Status'; AuthenticationApi.storeLoginRedirect(location);
import SystemLog from 'app/status/SystemLog'; navigate('/unauthorized');
import { Layout } from 'components'; }
import { AuthenticatedContext } from 'contexts/authentication'; return Promise.reject(error);
},
[location, navigate]
);
useEffect(() => {
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
}, [handleApiResponseError]);
const AuthenticatedRouting = () => {
const { me } = useContext(AuthenticatedContext);
return ( return (
<Layout> <Layout>
<Routes> <Routes>
<Route path="/dashboard/*" element={<Dashboard />} /> {features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
<Route path="/devices/*" element={<Devices />} /> <Route path="/network/*" element={<NetworkConnection />} />
<Route path="/sensors/*" element={<Sensors />} /> <Route path="/ap/*" element={<AccessPoint />} />
<Route path="/status/*" element={<Status />} /> {features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
<Route path="/help/*" element={<Help />} /> {features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
<Route path="/*" element={<Navigate to="/" />} /> {features.security && (
<Route
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} /> path="/security/*"
<Route path="/status/activity" element={<Activity />} /> element={
<Route path="/status/log" element={<SystemLog />} /> <RequireAdmin>
<Route path="/status/mqtt" element={<MqttStatus />} /> <Security />
<Route path="/status/ntp" element={<NTPStatus />} /> </RequireAdmin>
<Route path="/status/ap" element={<APStatus />} /> }
<Route path="/status/network" element={<NetworkStatus />} /> />
{me.admin && (
<>
<Route path="/settings" element={<Settings />} />
<Route path="/settings/version" element={<Version />} />
<Route path="/settings/application" element={<ApplicationSettings />} />
<Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} />
<Route path="/settings/modules" element={<Modules />} />
<Route path="/settings/upload" element={<DownloadUpload />} />
<Route path="/settings/network/*" element={<Network />} />
<Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} />
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} />
</>
)} )}
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
</Routes> </Routes>
</Layout> </Layout>
); );

View File

@@ -1,22 +1,10 @@
import type { FC } from 'react'; import { FC } from 'react';
import { CssBaseline } from '@mui/material'; import { CssBaseline } from '@mui/material';
import { import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
ThemeProvider, import { blueGrey, blue } from '@mui/material/colors';
createTheme,
responsiveFontSizes
} from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils'; import { RequiredChildrenProps } from './utils';
export const dialogStyle = {
'& .MuiDialog-paper': {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '1px'
}
};
const theme = responsiveFontSizes( const theme = responsiveFontSizes(
createTheme({ createTheme({
@@ -26,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,28 +1,34 @@
import { useContext, useState } from 'react'; import { FC, useContext, useState } from 'react';
import { toast } from 'react-toastify'; import { ValidateFieldsError } from 'async-validator';
import { useSnackbar } from 'notistack';
import { Box, Fab, Paper, Typography, Button } from '@mui/material';
import ForwardIcon from '@mui/icons-material/Forward'; import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material';
import * as AuthenticationApi from 'components/routing/authentication'; import * as AuthenticationApi from './api/authentication';
import { useRequest } from 'alova/client'; import { PROJECT_NAME } from './api/env';
import type { ValidateFieldsError } from 'async-validator'; import { AuthenticationContext } from './contexts/authentication';
import {
LanguageSelector,
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react';
import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn = () => { import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
import { SignInRequest } from './types';
import { ValidatedTextField } from './components';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
import { I18nContext } from './i18n/i18n-react';
import type { Locales } from './i18n/i18n-types';
import { loadLocaleAsync } from './i18n/i18n-util.async';
import { ReactComponent as NLflag } from './i18n/NL.svg';
import { ReactComponent as DEflag } from './i18n/DE.svg';
import { ReactComponent as GBflag } from './i18n/GB.svg';
import { ReactComponent as SVflag } from './i18n/SV.svg';
import { ReactComponent as PLflag } from './i18n/PL.svg';
import { ReactComponent as NOflag } from './i18n/NO.svg';
import { ReactComponent as FRflag } from './i18n/FR.svg';
const SignIn: FC = () => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
const { LL } = useI18nContext();
const [signInRequest, setSignInRequest] = useState<SignInRequest>({ const [signInRequest, setSignInRequest] = useState<SignInRequest>({
username: '', username: '',
@@ -31,30 +37,8 @@ const SignIn = () => {
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 } = 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: Error) => {
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({ SIGN_IN_REQUEST_VALIDATOR.messages({
@@ -62,15 +46,39 @@ const SignIn = () => {
}); });
try { try {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest); await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
await signIn(); signIn();
} catch (errors: any) {
setFieldErrors(errors);
setProcessing(false);
}
};
const signIn = async () => {
try {
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
authenticationContext.signIn(loginResponse.access_token);
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); if (error.response) {
if (error.response?.status === 401) {
enqueueSnackbar(LL.INVALID_LOGIN(), { variant: 'warning' });
}
} else {
enqueueSnackbar(extractErrorMessage(error, LL.ERROR()), { variant: 'error' });
}
setProcessing(false); setProcessing(false);
} }
}; };
const submitOnEnter = onEnterCallback(signIn); const submitOnEnter = onEnterCallback(signIn);
const { LL, setLocale, locale } = useContext(I18nContext);
const selectLocale = async (loc: Locales) => {
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
return ( return (
<Box <Box
display="flex" display="flex"
@@ -85,62 +93,81 @@ const SignIn = () => {
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>
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 0.5,
mx: 0.5
}
}}
>
<Button size="small" variant={locale === 'en' ? 'contained' : 'outlined'} onClick={() => selectLocale('en')}>
<GBflag style={{ width: 24 }} />
&nbsp;EN
</Button>
<Button size="small" variant={locale === 'de' ? 'contained' : 'outlined'} onClick={() => selectLocale('de')}>
<DEflag style={{ width: 24 }} />
&nbsp;DE
</Button>
<Button size="small" variant={locale === 'fr' ? 'contained' : 'outlined'} onClick={() => selectLocale('fr')}>
<FRflag style={{ width: 24 }} />
&nbsp;FR
</Button>
<Button size="small" variant={locale === 'nl' ? 'contained' : 'outlined'} onClick={() => selectLocale('nl')}>
<NLflag style={{ width: 24 }} />
&nbsp;NL
</Button>
<Button size="small" variant={locale === 'no' ? 'contained' : 'outlined'} onClick={() => selectLocale('no')}>
<NOflag style={{ width: 24 }} />
&nbsp;NO
</Button>
<Button size="small" variant={locale === 'pl' ? 'contained' : 'outlined'} onClick={() => selectLocale('pl')}>
<PLflag style={{ width: 24 }} />
&nbsp;PL
</Button>
<Button size="small" variant={locale === 'sv' ? 'contained' : 'outlined'} onClick={() => selectLocale('sv')}>
<SVflag style={{ width: 24 }} />
&nbsp;SV
</Button>
</Box>
<LanguageSelector />
<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={LL.USERNAME(0)}
value={signInRequest.username} value={signInRequest.username}
onChange={updateLoginRequestValue} onChange={updateLoginRequestValue}
margin="normal" margin="normal"
variant="outlined" variant="outlined"
slotProps={{ fullWidth
input: {
autoCapitalize: 'none',
autoCorrect: 'off'
}
}}
/> />
<ValidatedPasswordField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
disabled={processing} disabled={processing}
sx={{ type="password"
width: 240
}}
name="password" name="password"
label={LL.PASSWORD()} label={LL.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()} {LL.SIGN_IN()}
</Button> </Fab>
</Paper> </Paper>
</Box> </Box>
); );

View File

@@ -1,9 +1,16 @@
import type { APSettingsType, APStatusType } from 'types'; import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints'; import { APSettings, APStatus } from '../types';
import { AXIOS } from './endpoints';
export const readAPStatus = () => alovaInstance.Get<APStatusType>('/rest/apStatus'); export function readAPStatus(): AxiosPromise<APStatus> {
export const readAPSettings = () => return AXIOS.get('/apStatus');
alovaInstance.Get<APSettingsType>('/rest/apSettings'); }
export const updateAPSettings = (data: APSettingsType) =>
alovaInstance.Post<APSettingsType>('/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,151 +0,0 @@
import { alovaInstance } from 'api/endpoints';
import type {
APIcall,
Action,
Activity,
CoreData,
DashboardItem,
DeviceData,
DeviceEntity,
Entities,
EntityItem,
ModuleItem,
Modules,
Schedule,
ScheduleItem,
SensorData,
Settings,
WriteAnalogSensor,
WriteTemperatureSensor
} from '../app/main/types';
// Dashboard
export const readDashboard = () =>
alovaInstance.Get<DashboardItem[]>('/rest/dashboardData', {
responseType: 'arraybuffer' // uses msgpack
});
// Devices
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`);
export const readDeviceData = (id: number) =>
alovaInstance.Get<DeviceData>('/rest/deviceData', {
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
params: { id },
responseType: 'arraybuffer' // uses msgpack
});
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
alovaInstance.Post('/rest/writeDeviceValue', data);
// Application Settings
export const readSettings = () => alovaInstance.Get<Settings>('/rest/settings');
export const writeSettings = (data: Settings) =>
alovaInstance.Post('/rest/settings', data);
export const getBoardProfile = (boardProfile: string) =>
alovaInstance.Get('/rest/boardProfile', {
params: { boardProfile }
});
// Sensors
export const readSensorData = () =>
alovaInstance.Get<SensorData>('/rest/sensorData');
export const writeTemperatureSensor = (ts: WriteTemperatureSensor) =>
alovaInstance.Post('/rest/writeTemperatureSensor', ts);
export const writeAnalogSensor = (as: WriteAnalogSensor) =>
alovaInstance.Post('/rest/writeAnalogSensor', as);
// Activity
export const readActivity = () => alovaInstance.Get<Activity>('/rest/activity');
// API
export const API = (apiCall: APIcall) => alovaInstance.Post('/api', apiCall);
// Generic action
export const callAction = (action: Action) =>
alovaInstance.Post('/rest/action', action);
// SettingsCustomization
export const readDeviceEntities = (id: number) =>
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, {
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
params: { id },
responseType: 'arraybuffer',
transform(data) {
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({
...de,
o_m: de.m,
o_cn: de.cn,
o_mi: de.mi,
o_ma: de.ma
}));
}
});
export const resetCustomizations = () =>
alovaInstance.Post('/rest/resetCustomizations');
export const writeCustomizationEntities = (data: {
id: number;
entity_ids: string[];
}) => alovaInstance.Post('/rest/customizationEntities', data);
export const writeDeviceName = (data: { id: number; name: string }) =>
alovaInstance.Post('/rest/writeDeviceName', data);
// SettingsScheduler
export const readSchedule = () =>
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
transform(data) {
return (data as Schedule).schedule.map((si: ScheduleItem) => ({
...si,
o_id: si.id,
o_active: si.active,
o_deleted: si.deleted,
o_flags: si.flags,
o_time: si.time,
o_cmd: si.cmd,
o_value: si.value,
o_name: si.name
}));
}
});
export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data);
// Modules
export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
transform(data) {
return (data as Modules).modules.map((mi: ModuleItem) => ({
...mi,
o_enabled: mi.enabled,
o_license: mi.license
}));
}
});
export const writeModules = (data: {
key: string;
enabled: boolean;
license: string;
}) => alovaInstance.Post('/rest/modules', data);
// CustomEntities
export const readCustomEntities = () =>
alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
transform(data) {
return (data as Entities).entities.map((ei: EntityItem) => ({
...ei,
o_id: ei.id,
o_ram: ei.ram,
o_device_id: ei.device_id,
o_type_id: ei.type_id,
o_offset: ei.offset,
o_factor: ei.factor,
o_uom: ei.uom,
o_value_type: ei.value_type,
o_name: ei.name,
o_writeable: ei.writeable,
o_value: ei.value,
o_deleted: ei.deleted
}));
}
});
export const writeCustomEntities = (data: Entities) =>
alovaInstance.Post('/rest/customEntities', data);

View File

@@ -1,19 +1,29 @@
import type { Path } from 'react-router-dom'; import { AxiosPromise } from 'axios';
import * as H from 'history';
import jwtDecode from 'jwt-decode';
import { Path } from 'react-router-dom';
import type * as H from 'history'; import { Features, Me, SignInRequest, SignInResponse } from '../types';
import { jwtDecode } from 'jwt-decode';
import type { Me, SignInRequest, SignInResponse } from 'types';
import { ACCESS_TOKEN, alovaInstance } from '../../api/endpoints'; 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 = () => export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
alovaInstance.Get('/rest/verifyAuthorization');
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;
} }
@@ -30,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 || `/dashboard`, 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,63 +1,105 @@
import { type AlovaXHRResponse, xhrRequestAdapter } from '@alova/adapter-xhr'; import axios, { AxiosPromise, CancelToken, AxiosProgressEvent } from 'axios';
import { createAlova } from 'alova';
import ReactHook from 'alova/react';
import { unpack } from './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);
export const alovaInstance = createAlova({ export const AXIOS = axios.create({
statesHook: ReactHook, baseURL: API_BASE_URL,
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none headers: {
cacheFor: null, // disable cache 'Content-Type': 'application/json'
// cacheFor: {
// GET: {
// mode: 'memory',
// expire: 60 * 10 * 1000 // 60 seconds in cache
// }
// },
requestAdapter: xhrRequestAdapter(),
beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization =
'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
// for simulating vrey slow networks
// return new Promise((resolve) => {
// const random = 3000 + Math.random() * 2000;
// setTimeout(resolve, Math.floor(random));
// });
}, },
transformRequest: [
responded: { (data, headers) => {
onSuccess: async (response: AlovaXHRResponse) => { 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: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
} }
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: AxiosProgressEvent) => 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 || {})
});
};

2
interface/src/api/env.ts Normal file
View File

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

View File

@@ -1,15 +1,25 @@
import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'types'; import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints'; import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
export const readNetworkStatus = () => import { AXIOS } from './endpoints';
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks'); export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
export const listNetworks = () => return AXIOS.get('/networkStatus');
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', { }
timeout: 20000 // 20 seconds
}); export function scanNetworks(): AxiosPromise<void> {
export const readNetworkSettings = () => return AXIOS.get('/scanNetworks');
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings'); }
export const updateNetworkSettings = (wifiSettings: NetworkSettingsType) =>
alovaInstance.Post<NetworkSettingsType>('/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,14 +1,20 @@
import type { NTPSettingsType, NTPStatusType, Time } from 'types'; import { AxiosPromise } from 'axios';
import { NTPSettings, NTPStatus, Time } from '../types';
import { alovaInstance } from './endpoints'; import { AXIOS } from './endpoints';
export const readNTPStatus = () => export function readNTPStatus(): AxiosPromise<NTPStatus> {
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus'); return AXIOS.get('/ntpStatus');
}
export const readNTPSettings = () => export function readNTPSettings(): AxiosPromise<NTPSettings> {
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {}); return AXIOS.get('/ntpSettings');
export const updateNTPSettings = (data: NTPSettingsType) => }
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
export const updateTime = (data: Time) => export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
alovaInstance.Post<Time>('/rest/time', data); return AXIOS.post('/ntpSettings', ntpSettings);
}
export function updateTime(time: Time): AxiosPromise<Time> {
return AXIOS.post('/time', time);
}

View File

@@ -1,14 +1,17 @@
import type { SecuritySettingsType, Token } from 'types'; import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints'; import { SecuritySettings, Token } from '../types';
export const readSecuritySettings = () => import { AXIOS } from './endpoints';
alovaInstance.Get<SecuritySettingsType>('/rest/securitySettings');
export const updateSecuritySettings = (securitySettings: SecuritySettingsType) => 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,36 +1,44 @@
import type { LogSettings, SystemStatus } from 'types'; import { AxiosPromise } from 'axios';
import { alovaInstance, alovaInstanceGH } from './endpoints'; import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
// systemStatus - also used to ping in Restart monitor for pinging import { AXIOS, AXIOS_BIN, FileUploadConfig, startUploadFile } from './endpoints';
export const readSystemStatus = () =>
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
// SystemLog export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
export const readLogSettings = () => return AXIOS.get('/systemStatus', { timeout });
alovaInstance.Get<LogSettings>(`/rest/logSettings`); }
export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from GitHub export function restart(): AxiosPromise<void> {
export const getStableVersion = () => return AXIOS.post('/restart');
alovaInstanceGH.Get('latest', { }
transform(response: { data: { name: string } }) {
return response.data.name.substring(1);
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
transform(response: { data: { name: string } }) {
return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
}
});
export const uploadFile = (file: File) => { export function partition(): AxiosPromise<void> {
const formData = new FormData(); return AXIOS.post('/partition');
formData.append('file', file); }
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000 // override timeout for uploading firmware - 1 minute export function factoryReset(): AxiosPromise<void> {
}); return AXIOS.post('/factoryReset');
}; }
export function readOTASettings(): AxiosPromise<OTASettings> {
return AXIOS.get('/otaSettings');
}
export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
return AXIOS.post('/otaSettings', otaSettings);
}
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');
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,342 +0,0 @@
import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCustomEntities, writeCustomEntities } from '../../api/app';
import SettingsCustomEntitiesDialog from './CustomEntitiesDialog';
import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { Entities, EntityItem } from './types';
import { entityItemValidation } from './validators';
const CustomEntities = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedEntityItem, setSelectedEntityItem] = useState<EntityItem>();
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.CUSTOM_ENTITIES(0));
const {
data: entities,
send: fetchEntities,
error
} = useRequest(readCustomEntities, {
initialData: []
});
useInterval(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
}, 3000);
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
function hasEntityChanged(ei: EntityItem) {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
(ei?.name || '') !== (ei?.o_name || '') ||
ei.device_id !== ei.o_device_id ||
ei.type_id !== ei.o_type_id ||
ei.offset !== ei.o_offset ||
ei.uom !== ei.o_uom ||
ei.factor !== ei.o_factor ||
ei.value_type !== ei.o_value_type ||
ei.writeable !== ei.o_writeable ||
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}
const entity_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 90px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(3) {
text-align: center;
}
&:nth-of-type(4) {
text-align: center;
}
&:nth-of-type(5) {
text-align: center;
}
&:nth-of-type(6) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
});
const saveEntities = async () => {
await writeEntities({
entities: entities
.filter((ei) => !ei.deleted)
.map((condensed_ei) => ({
id: condensed_ei.id,
ram: condensed_ei.ram,
name: condensed_ei.name,
device_id: condensed_ei.device_id,
type_id: condensed_ei.type_id,
offset: condensed_ei.offset,
factor: condensed_ei.factor,
uom: condensed_ei.uom,
writeable: condensed_ei.writeable,
value_type: condensed_ei.value_type,
value: condensed_ei.value
}))
})
.then(() => {
toast.success(LL.ENTITIES_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchEntities();
setNumChanges(0);
});
};
const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false);
setSelectedEntityItem(ei);
setDialogOpen(true);
}, []);
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const addEntityItem = () => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
name: '',
ram: 0,
device_id: '0',
type_id: '0',
offset: 0,
factor: 1,
uom: 0,
value_type: 0,
writeable: false,
deleted: false,
value: ''
});
setDialogOpen(true);
};
function formatValue(value: unknown, uom: number) {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
: (value as string);
}
function showHex(value: number, digit: number) {
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
}
const renderEntity = () => {
if (!entities) {
return <FormLoader onRetry={fetchEntities} errorMessage={error?.message} />;
}
return (
<Table
data={{
nodes: entities
.filter((ei) => !ei.deleted)
.sort((a, b) => a.name.localeCompare(b.name))
}}
theme={entity_theme}
layout={{ custom: true }}
>
{(tableList: EntityItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell>{LL.NAME(0)}</HeaderCell>
<HeaderCell stiff>{LL.ID_OF(LL.DEVICE())}</HeaderCell>
<HeaderCell stiff>{LL.ID_OF(LL.TYPE(1))}</HeaderCell>
<HeaderCell stiff>{LL.OFFSET()}</HeaderCell>
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ei: EntityItem) => (
<Row key={ei.name} item={ei} onClick={() => editEntityItem(ei)}>
<Cell>
{ei.name}&nbsp;
{ei.writeable && (
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</Cell>
<Cell>
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
</Cell>
<Cell>{ei.ram === 1 ? '' : showHex(ei.type_id as number, 3)}</Cell>
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
<Cell>
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
</Cell>
<Cell>{formatValue(ei.value, ei.uom)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Box mb={2} color="warning.main">
<Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
</Box>
{renderEntity()}
{selectedEntityItem && (
<SettingsCustomEntitiesDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedEntityItem}
validator={entityItemValidation(entities, selectedEntityItem)}
/>
)}
<Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges > 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveEntities}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box flexWrap="nowrap" whiteSpace="nowrap">
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addEntityItem}
>
{LL.ADD(0)}
</Button>
</Box>
</Box>
</SectionContent>
);
};
export default CustomEntities;

View File

@@ -1,342 +0,0 @@
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
MenuItem,
TextField
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { validate } from 'validators';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types';
interface CustomEntitiesDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ei: EntityItem) => void;
selectedItem: EntityItem;
validator: Schema;
}
const CustomEntitiesDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator
}: CustomEntitiesDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// convert to hex strings straight away
setEditItem({
...selectedItem,
device_id: selectedItem.device_id.toString(16).toUpperCase(),
type_id: selectedItem.type_id.toString(16).toUpperCase()
});
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
if (typeof editItem.device_id === 'string') {
editItem.device_id = parseInt(editItem.device_id, 16);
}
if (typeof editItem.type_id === 'string') {
editItem.type_id = parseInt(editItem.type_id, 16);
}
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()}&nbsp;{LL.ENTITY()}
</DialogTitle>
<DialogContent dividers>
<Box display="flex" flexWrap="wrap" mb={1}>
<Box flexWrap="nowrap" whiteSpace="nowrap" />
</Box>
<Grid container spacing={2} rowSpacing={0}>
<Grid size={12}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="name"
label={LL.NAME(0)}
value={editItem.name}
margin="normal"
fullWidth
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
name="ram"
label={LL.VALUE(0) + ' ' + LL.TYPE(1)}
value={editItem.ram}
variant="outlined"
onChange={updateFormValue}
margin="normal"
fullWidth
select
>
<MenuItem value={0}>EMS-{LL.VALUE(1)}</MenuItem>
<MenuItem value={1}>RAM-{LL.VALUE(1)}</MenuItem>
</TextField>
</Grid>
{editItem.ram === 1 && (
<Grid>
<TextField
name="value"
label={LL.DEFAULT(0) + ' ' + LL.VALUE(0)}
type="string"
value={editItem.value as string}
variant="outlined"
onChange={updateFormValue}
fullWidth
margin="normal"
/>
</Grid>
)}
{editItem.ram === 0 && (
<>
<Grid mt={3} size={9}>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.writeable}
onChange={updateFormValue}
name="writeable"
/>
}
label={LL.WRITEABLE()}
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="device_id"
label={LL.ID_OF(LL.DEVICE())}
margin="normal"
sx={{ width: '11ch' }}
type="string"
value={editItem.device_id as string}
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">0x</InputAdornment>
)
},
htmlInput: { style: { textTransform: 'uppercase' } }
}}
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="type_id"
label={LL.ID_OF(LL.TYPE(1))}
margin="normal"
sx={{ width: '11ch' }}
type="string"
value={editItem.type_id as string}
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">0x</InputAdornment>
)
},
htmlInput: { style: { textTransform: 'uppercase' } }
}}
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="offset"
label={LL.OFFSET()}
margin="normal"
sx={{ width: '11ch' }}
type="number"
value={numberValue(editItem.offset)}
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
name="value_type"
label={LL.VALUE(0) + ' ' + LL.TYPE(1)}
value={editItem.value_type}
variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={DeviceValueType.BOOL}>
{DeviceValueTypeNames[DeviceValueType.BOOL]}
</MenuItem>
<MenuItem value={DeviceValueType.INT8}>
{DeviceValueTypeNames[DeviceValueType.INT8]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT8}>
{DeviceValueTypeNames[DeviceValueType.UINT8]}
</MenuItem>
<MenuItem value={DeviceValueType.INT16}>
{DeviceValueTypeNames[DeviceValueType.INT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT16}>
{DeviceValueTypeNames[DeviceValueType.UINT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT24}>
{DeviceValueTypeNames[DeviceValueType.UINT24]}
</MenuItem>
<MenuItem value={DeviceValueType.TIME}>
{DeviceValueTypeNames[DeviceValueType.TIME]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT32}>
{DeviceValueTypeNames[DeviceValueType.UINT32]}
</MenuItem>
<MenuItem value={DeviceValueType.STRING}>
{DeviceValueTypeNames[DeviceValueType.STRING]}
</MenuItem>
</TextField>
</Grid>
{editItem.value_type !== DeviceValueType.BOOL &&
editItem.value_type !== DeviceValueType.STRING && (
<>
<Grid>
<TextField
name="factor"
label={LL.FACTOR()}
value={numberValue(editItem.factor)}
variant="outlined"
onChange={updateFormValue}
sx={{ width: '11ch' }}
margin="normal"
type="number"
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
<Grid>
<TextField
name="uom"
label={LL.UNIT()}
value={editItem.uom}
margin="normal"
sx={{ width: '11ch' }}
onChange={updateFormValue}
select
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
</>
)}
{editItem.value_type === DeviceValueType.STRING &&
editItem.device_id !== '0' && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="factor"
label="Bytes"
value={numberValue(editItem.factor)}
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
margin="normal"
type="number"
/>
</Grid>
)}
</>
)}
</Grid>
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default CustomEntitiesDialog;

View File

@@ -1,753 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router-dom';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SaveIcon from '@mui/icons-material/Save';
import SearchIcon from '@mui/icons-material/Search';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
Link,
MenuItem,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import RestartMonitor from 'app/status/RestartMonitor';
import {
BlockNavigation,
ButtonRow,
MessageBox,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import {
API,
readCoreData,
readDeviceEntities,
resetCustomizations,
writeCustomizationEntities,
writeDeviceName
} from '../../api/app';
import SettingsCustomizationsDialog from './CustomizationsDialog';
import EntityMaskToggle from './EntityMaskToggle';
import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = window.location.origin + '/api/';
const Customizations = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [restarting, setRestarting] = useState<boolean>(false);
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]);
const [confirmReset, setConfirmReset] = useState<boolean>(false);
const [selectedFilters, setSelectedFilters] = useState<number>(0);
const [search, setSearch] = useState('');
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [rename, setRename] = useState<boolean>(false);
useLayoutTitle(LL.CUSTOMIZATIONS());
// fetch devices first from coreData
const { data: devices, send: fetchCoreData } = useRequest(readCoreData);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const [selectedDevice, setSelectedDevice] = useState<number>(
Number(useLocation().state) || -1
);
const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] =
useState<string>(''); // needed for API URL
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
immediate: false
});
const { send: sendDeviceName } = useRequest(
(data: { id: number; name: string }) => writeDeviceName(data),
{
immediate: false
}
);
const { send: sendCustomizationEntities } = useRequest(
(data: { id: number; entity_ids: string[] }) => writeCustomizationEntities(data),
{
immediate: false
}
);
const { send: sendDeviceEntities } = useRequest(
(data: number) => readDeviceEntities(data),
{
initialData: [],
immediate: false
}
).onSuccess((event) => {
setOriginalSettings(event.data);
});
const setOriginalSettings = (data: DeviceEntity[]) => {
setDeviceEntities(
data.map((de) => ({
...de,
o_m: de.m,
o_cn: de.cn,
o_mi: de.mi,
o_ma: de.ma
}))
);
};
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const entities_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(3) {
text-align: right;
}
&:nth-of-type(4) {
text-align: right;
}
&:last-of-type {
text-align: right;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
&:nth-of-type(1) .th {
text-align: center;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&.tr.tr-body.row-select.row-select-single-selected {
background-color: #3d4752;
}
&:hover .td {
border-top: 1px solid #177ac9;
background-color: #177ac9;
}
`,
Cell: `
&:nth-of-type(2) {
padding: 8px;
}
&:nth-of-type(3) {
padding-right: 4px;
}
&:nth-of-type(4) {
padding-right: 4px;
}
&:last-of-type {
padding-right: 8px;
}
`
});
function hasEntityChanged(de: DeviceEntity) {
return (
(de?.cn || '') !== (de?.o_cn || '') ||
de.m !== de.o_m ||
de.ma !== de.o_ma ||
de.mi !== de.o_mi
);
}
useEffect(() => {
if (deviceEntities.length) {
setNumChanges(
deviceEntities
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
}
}, [deviceEntities]);
useEffect(() => {
if (devices && selectedDevice !== -1) {
void sendDeviceEntities(selectedDevice);
const index = devices.devices.findIndex((d) => d.id === selectedDevice);
if (index === -1) {
setSelectedDevice(-1);
setSelectedDeviceTypeNameURL('');
} else {
setSelectedDeviceTypeNameURL(devices.devices[index].url || '');
setSelectedDeviceName(devices.devices[index].n);
setNumChanges(0);
setRestartNeeded(false);
}
}
}, [devices, selectedDevice]);
function formatValue(value: unknown) {
if (typeof value === 'number') {
return new Intl.NumberFormat().format(value);
} else if (value === undefined) {
return '';
} else if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return value as string;
}
const formatName = (de: DeviceEntity, withShortname: boolean) =>
(de.n && de.n[0] === '!'
? de.t
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
: LL.COMMAND(1) + ': ' + de.n.slice(1)
: de.cn && de.cn !== ''
? de.t
? de.t + ' ' + de.cn
: de.cn
: de.t
? de.t + ' ' + de.n
: de.n) + (withShortname ? ' ' + de.id : '');
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
for (const entry of newMask) {
new_mask |= Number(entry);
}
return new_mask;
};
const getMaskString = (m: number) => {
const new_masks: string[] = [];
if ((m & 1) === 1) {
new_masks.push('1');
}
if ((m & 2) === 2) {
new_masks.push('2');
}
if ((m & 4) === 4) {
new_masks.push('4');
}
if ((m & 8) === 8) {
new_masks.push('8');
}
if ((m & 128) === 128) {
new_masks.push('128');
}
return new_masks;
};
const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).includes(search);
const maskDisabled = (set: boolean) => {
setDeviceEntities(
deviceEntities.map(function (de) {
if (filter_entity(de)) {
return {
...de,
m: set
? de.m |
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
};
} else {
return de;
}
})
);
};
const resetCustomization = async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
} catch (error) {
toast.error((error as Error).message);
} finally {
setConfirmReset(false);
}
};
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities(
deviceEntities?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
)
);
};
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
return;
}
if (de.cn === undefined) {
de.cn = '';
}
setSelectedDeviceEntity(de);
setDialogOpen(true);
}, []);
const saveCustomization = async () => {
if (devices && deviceEntities && selectedDevice !== -1) {
const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
);
// check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > 2000) {
toast.warning(LL.CUSTOMIZATIONS_FULL());
return;
}
await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
}).catch((error: Error) => {
if (error.message === 'Reboot required') {
setRestartNeeded(true);
} else {
toast.error(error.message);
}
});
setOriginalSettings(deviceEntities);
}
};
const renameDevice = async () => {
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
.then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1));
})
.finally(async () => {
setRename(false);
await fetchCoreData();
});
};
const renderDeviceList = () => (
<>
<Box mb={1} color="warning.main">
<Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
</Box>
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
{rename ? (
<TextField
name="device"
label={LL.EMS_DEVICE()}
fullWidth
variant="outlined"
value={selectedDeviceName}
onChange={(e) => setSelectedDeviceName(e.target.value)}
margin="normal"
/>
) : (
<TextField
name="device"
label={LL.EMS_DEVICE()}
variant="outlined"
value={selectedDevice}
disabled={numChanges !== 0}
onChange={(e) => setSelectedDevice(parseInt(e.target.value))}
margin="normal"
style={{ minWidth: '50%' }}
select
>
<MenuItem disabled key={-1} value={-1}>
{LL.SELECT_DEVICE()}...
</MenuItem>
{devices.devices.map(
(device: Device) =>
device.id < 90 && (
<MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn})
</MenuItem>
)
)}
</TextField>
)}
{selectedDevice !== -1 &&
(rename ? (
<>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => setRename(false)}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SaveIcon />}
variant="outlined"
onClick={() => renameDevice()}
>
{LL.RENAME()}
</Button>
</>
) : (
<Button
startIcon={<EditIcon />}
variant="outlined"
onClick={() => setRename(true)}
>
{LL.RENAME()}
</Button>
))}
</Box>
</>
);
const renderDeviceData = () => {
const shown_data = deviceEntities.filter((de) => filter_entity(de));
return (
<>
<Box color="warning.main">
<Typography variant="body2" mt={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
&nbsp;&nbsp;
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
&nbsp;&nbsp;
<OptionIcon type="api_mqtt_exclude" isSet={true} />=
{LL.CUSTOMIZATIONS_HELP_4()}&nbsp;&nbsp;
<OptionIcon type="web_exclude" isSet={true} />=
{LL.CUSTOMIZATIONS_HELP_5()}&nbsp;&nbsp;
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
</Typography>
</Box>
<Grid
container
mb={1}
mt={0}
spacing={2}
direction="row"
justifyContent="flex-start"
alignItems="center"
>
<Grid>
<TextField
size="small"
variant="outlined"
placeholder={LL.SEARCH()}
onChange={(event) => {
setSearch(event.target.value);
}}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="primary" sx={{ fontSize: 16 }} />
</InputAdornment>
)
}
}}
/>
</Grid>
<Grid>
<ToggleButtonGroup
size="small"
color="secondary"
value={getMaskString(selectedFilters)}
onChange={(event, mask: string[]) => {
setSelectedFilters(getMaskNumber(mask));
}}
>
<ToggleButton value="8">
<OptionIcon type="favorite" isSet={true} />
</ToggleButton>
<ToggleButton value="4">
<OptionIcon type="readonly" isSet={true} />
</ToggleButton>
<ToggleButton value="2">
<OptionIcon type="api_mqtt_exclude" isSet={true} />
</ToggleButton>
<ToggleButton value="1">
<OptionIcon type="web_exclude" isSet={true} />
</ToggleButton>
<ToggleButton value="128">
<OptionIcon type="deleted" isSet={true} />
</ToggleButton>
</ToggleButtonGroup>
</Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="inherit"
onClick={() => maskDisabled(false)}
>
{LL.SET_ALL()}&nbsp;
<OptionIcon type="api_mqtt_exclude" isSet={false} />
<OptionIcon type="web_exclude" isSet={false} />
</Button>
</Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="inherit"
onClick={() => maskDisabled(true)}
>
{LL.SET_ALL()}&nbsp;
<OptionIcon type="api_mqtt_exclude" isSet={true} />
<OptionIcon type="web_exclude" isSet={true} />
</Button>
</Grid>
<Grid>
<Typography variant="subtitle2" color="primary">
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography>
</Grid>
</Grid>
<Table
data={{ nodes: shown_data }}
theme={entities_theme}
layout={{ custom: true }}
>
{(tableList: DeviceEntity[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>{LL.OPTIONS()}</HeaderCell>
<HeaderCell resize>{LL.NAME(1)}</HeaderCell>
<HeaderCell stiff>{LL.MIN()}</HeaderCell>
<HeaderCell stiff>{LL.MAX()}</HeaderCell>
<HeaderCell resize>{LL.VALUE(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((de: DeviceEntity) => (
<Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}>
<Cell stiff>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell>
<Cell>
{formatName(de, false)}&nbsp;(
<Link
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
>
{de.id}
</Link>
)
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}
</Cell>
<Cell>
{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)}
</Cell>
<Cell>{formatValue(de.v)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
</>
);
};
const renderResetDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmReset}
onClose={() => setConfirmReset(false)}
>
<DialogTitle>{LL.RESET(1)}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmReset(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={resetCustomization}
color="error"
>
{LL.RESET(0)}
</Button>
</DialogActions>
</Dialog>
);
const renderContent = () => (
<>
{devices && renderDeviceList()}
{selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded ? (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
onClick={doRestart}
>
{LL.RESTART()}
</Button>
</MessageBox>
) : (
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
onClick={() => devices && sendDeviceEntities(selectedDevice)}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveCustomization}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
{!rename && (
<ButtonRow mt={1}>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmReset(true)}
>
{LL.RESET(0)}
</Button>
</ButtonRow>
)}
</Box>
)}
{renderResetDialog()}
</>
);
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : renderContent()}
{selectedDeviceEntity && (
<SettingsCustomizationsDialog
open={dialogOpen}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedDeviceEntity}
/>
)}
</SectionContent>
);
};
export default Customizations;

View File

@@ -1,177 +0,0 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
import DoneIcon from '@mui/icons-material/Done';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import EntityMaskToggle from './EntityMaskToggle';
import { DeviceEntityMask } from './types';
import type { DeviceEntity } from './types';
interface SettingsCustomizationsDialogProps {
open: boolean;
onClose: () => void;
onSave: (di: DeviceEntity) => void;
selectedItem: DeviceEntity;
}
const CustomizationsDialog = ({
open,
onClose,
onSave,
selectedItem
}: SettingsCustomizationsDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue(setEditItem);
const isWriteableNumber =
typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
useEffect(() => {
if (open) {
setError(false);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = () => {
if (
isWriteableNumber &&
editItem.mi &&
editItem.ma &&
editItem.mi > editItem?.ma
) {
setError(true);
} else {
onSave(editItem);
}
};
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setEditItem({ ...editItem, m: updatedItem.m });
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogContent dividers>
<Grid container>
<Typography variant="body2" color="warning.main">
{LL.ID_OF(LL.ENTITY())}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.id}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.n}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.WRITEABLE()}:&nbsp;
</Typography>
<Typography variant="body2">
{editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)}
</Typography>
</Grid>
<Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box>
<Grid container spacing={2}>
<Grid>
<TextField
name="cn"
label={LL.NEW_NAME_OF(LL.ENTITY())}
value={editItem.cn}
sx={{ width: '30ch' }}
onChange={updateFormValue}
/>
</Grid>
{isWriteableNumber && (
<>
<Grid>
<TextField
name="mi"
label={LL.MIN()}
value={numberValue(editItem.mi)}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
name="ma"
label={LL.MAX()}
value={numberValue(editItem.ma)}
sx={{ width: '11ch' }}
type="number"
onChange={updateFormValue}
/>
</Grid>
</>
)}
</Grid>
{error && (
<Typography variant="body2" color="error" mt={2}>
Error: Check min and max values
</Typography>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default CustomizationsDialog;

View File

@@ -1,366 +0,0 @@
import { useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import {
Box,
IconButton,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { Body, Cell, Row, Table } from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { CellTree, useTree } from '@table-library/react-table-library/tree';
import { useRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils';
import { readDashboard, writeDeviceValue } from '../../api/app';
import DeviceIcon from './DeviceIcon';
import DevicesDialog from './DevicesDialog';
import { formatValue } from './deviceValue';
import {
type DashboardItem,
DeviceEntityMask,
DeviceType,
type DeviceValue
} from './types';
import { deviceValueItemValidation } from './validators';
const Dashboard = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
useLayoutTitle(LL.DASHBOARD());
const [showAll, setShowAll] = usePersistState(true, 'showAll');
const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState<boolean>(false);
const [parentNodes, setParentNodes] = useState<number>(0);
const [selectedDashboardItem, setSelectedDashboardItem] =
useState<DashboardItem>();
const {
data,
send: fetchDashboard,
error,
loading
} = useRequest(readDashboard, {
initialData: []
}).onSuccess((event) => {
if (event.data.length !== parentNodes) {
setParentNodes(event.data.length); // count number of parents/devices
}
});
const { loading: submitting, send: sendDeviceValue } = useRequest(
(data: { id: number; c: string; v: unknown }) => writeDeviceValue(data),
{
immediate: false
}
);
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
if (!selectedDashboardItem) {
return;
}
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
.then(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setDeviceValueDialogOpen(false);
setSelectedDashboardItem(undefined);
});
};
const dashboard_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 28px;
}
`,
Row: `
cursor: pointer;
background-color: #1e1e1e;
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: right;
}
&:nth-of-type(3) {
text-align: right;
}
`
});
const tree = useTree(
{ nodes: data },
{
onChange: undefined // not used but needed
},
{
treeIcon: {
margin: '4px',
iconDefault: null,
iconRight: (
<ChevronRightIcon
sx={{ fontSize: 16, verticalAlign: 'middle' }}
color="info"
/>
),
iconDown: (
<ExpandMoreIcon
sx={{ fontSize: 16, verticalAlign: 'middle' }}
color="info"
/>
)
},
indentation: 45
}
);
useInterval(() => {
if (!deviceValueDialogOpen) {
void fetchDashboard();
}
}, 3000);
useEffect(() => {
showAll
? tree.fns.onAddAll(data.map((item: DashboardItem) => item.id)) // expand tree
: tree.fns.onRemoveAll(); // collapse tree
}, [parentNodes]);
const showType = (n?: string, t?: number) => {
// if we have a name show it
if (n) {
return n;
}
if (t) {
// otherwise pick translation based on type
switch (t) {
case DeviceType.CUSTOM:
return LL.CUSTOM_ENTITIES(0);
case DeviceType.ANALOGSENSOR:
return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS();
case DeviceType.SCHEDULER:
return LL.SCHEDULER();
default:
break;
}
}
return '';
};
const showName = (di: DashboardItem) => {
if (di.id < 100) {
// if its a device (parent node) and has entities
if (di.nodes?.length) {
return (
<>
<span style="font-size: 14px">
<DeviceIcon type_id={di.t ?? 0} />
&nbsp;&nbsp;{showType(di.n, di.t)}
</span>
<span style={{ color: 'lightblue' }}>&nbsp;({di.nodes?.length})</span>
</>
);
}
}
if (di.dv) {
return <span style="color:lightgrey">{di.dv.id.slice(2)}</span>;
}
};
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const editDashboardValue = (di: DashboardItem) => {
if (me.admin && di.dv?.c) {
setSelectedDashboardItem(di);
setDeviceValueDialogOpen(true);
}
};
const handleShowAll = (
event: React.MouseEvent<HTMLElement>,
toggle: boolean | null
) => {
if (toggle !== null) {
tree.fns.onToggleAll({});
setShowAll(toggle);
}
};
const renderContent = () => {
if (!data) {
return <FormLoader onRetry={fetchDashboard} errorMessage={error?.message} />;
}
return (
<>
<Box
sx={{
backgroundColor: 'black',
pt: 1,
pl: 2
}}
>
<Grid container spacing={0} justifyContent="flex-start">
<Grid size={11}>
<Typography mb={2} variant="body1" color="warning">
{LL.DASHBOARD_1()}.
</Typography>
</Grid>
<Grid size={1} alignItems="end">
<ToggleButtonGroup
color="primary"
size="small"
value={showAll}
exclusive
onChange={handleShowAll}
>
<ToggleButton value={true}>
<UnfoldMoreIcon sx={{ fontSize: 14 }} />
</ToggleButton>
<ToggleButton value={false}>
<UnfoldLessIcon sx={{ fontSize: 14 }} />
</ToggleButton>
</ToggleButtonGroup>
</Grid>
</Grid>
</Box>
<Box
padding={1}
justifyContent="center"
flexDirection="column"
sx={{
borderRadius: 1,
border: '1px solid grey'
}}
>
<IconContext.Provider
value={{
color: 'lightblue',
size: '16',
style: { verticalAlign: 'middle' }
}}
>
{!loading && data.length === 0 ? (
<Typography variant="subtitle2" color="secondary">
{LL.NO_DATA()}
</Typography>
) : (
<Table
data={{ nodes: data }}
theme={dashboard_theme}
layout={{ custom: true }}
tree={tree}
>
{(tableList: DashboardItem[]) => (
<Body>
{tableList.map((di: DashboardItem) => (
<Row
key={di.id}
item={di}
onClick={() => editDashboardValue(di)}
>
{di.id > 99 ? (
<>
<Cell>{showName(di)}</Cell>
<Cell>
<Tooltip
placement="left"
title={formatValue(LL, di.dv?.v, di.dv?.u)}
arrow
>
<span style={{ color: 'lightgrey' }}>
{formatValue(LL, di.dv?.v, di.dv?.u)}
</span>
</Tooltip>
</Cell>
<Cell>
{me.admin &&
di.dv?.c &&
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
<IconButton
size="small"
onClick={() => editDashboardValue(di)}
>
<EditIcon
color="primary"
sx={{ fontSize: 16 }}
/>
</IconButton>
)}
</Cell>
</>
) : (
<>
<CellTree item={di}>{showName(di)}</CellTree>
<Cell />
<Cell />
</>
)}
</Row>
))}
</Body>
)}
</Table>
)}
</IconContext.Provider>
</Box>
</>
);
};
return (
<SectionContent>
{renderContent()}
{selectedDashboardItem && selectedDashboardItem.dv && (
<DevicesDialog
open={deviceValueDialogOpen}
onClose={() => setDeviceValueDialogOpen(false)}
onSave={deviceValueDialogSave}
selectedItem={selectedDashboardItem.dv}
writeable={true}
validator={deviceValueItemValidation(selectedDashboardItem.dv)}
progress={submitting}
/>
)}
</SectionContent>
);
};
export default Dashboard;

View File

@@ -1,53 +0,0 @@
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
import { CgSmartHomeBoiler } from 'react-icons/cg';
import { FaSolarPanel } from 'react-icons/fa';
import { GiHeatHaze, GiTap } from 'react-icons/gi';
import { MdPlaylistAdd } from 'react-icons/md';
import { MdMoreTime } from 'react-icons/md';
import {
MdOutlineDevices,
MdOutlinePool,
MdOutlineSensors,
MdThermostatAuto
} from 'react-icons/md';
import { PiFan, PiGauge } from 'react-icons/pi';
import { TiFlowSwitch, TiThermometer } from 'react-icons/ti';
import { VscVmConnect } from 'react-icons/vsc';
import type { SvgIconProps } from '@mui/material';
import { DeviceType } from './types';
const deviceIconLookup: {
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined;
} = {
[DeviceType.TEMPERATURESENSOR]: TiThermometer,
[DeviceType.ANALOGSENSOR]: PiGauge,
[DeviceType.BOILER]: CgSmartHomeBoiler,
[DeviceType.HEATSOURCE]: CgSmartHomeBoiler,
[DeviceType.THERMOSTAT]: MdThermostatAuto,
[DeviceType.MIXER]: AiOutlineControl,
[DeviceType.SOLAR]: FaSolarPanel,
[DeviceType.HEATPUMP]: GiHeatHaze,
[DeviceType.GATEWAY]: AiOutlineGateway,
[DeviceType.SWITCH]: TiFlowSwitch,
[DeviceType.CONTROLLER]: VscVmConnect,
[DeviceType.CONNECT]: VscVmConnect,
[DeviceType.ALERT]: AiOutlineAlert,
[DeviceType.EXTENSION]: MdOutlineDevices,
[DeviceType.WATER]: GiTap,
[DeviceType.POOL]: MdOutlinePool,
[DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: undefined,
[DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan
};
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => {
const Icon = deviceIconLookup[type_id];
return Icon ? <Icon /> : null;
};
export default DeviceIcon;

View File

@@ -1,769 +0,0 @@
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState
} from 'react';
import { IconContext } from 'react-icons';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import EditIcon from '@mui/icons-material/Edit';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import DownloadIcon from '@mui/icons-material/GetApp';
import HighlightOffIcon from '@mui/icons-material/HighlightOff';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StarIcon from '@mui/icons-material/Star';
import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined';
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemText,
Tooltip,
type TooltipProps,
Typography,
styled,
tooltipClasses
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { useRowSelect } from '@table-library/react-table-library/select';
import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import type { Action, State } from '@table-library/react-table-library/types/common';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { MessageBox, SectionContent, useLayoutTitle } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCoreData, readDeviceData, writeDeviceValue } from '../../api/app';
import DeviceIcon from './DeviceIcon';
import DevicesDialog from './DevicesDialog';
import { formatValue } from './deviceValue';
import { DeviceEntityMask, DeviceType, DeviceValueUOM_s } from './types';
import type { Device, DeviceValue } from './types';
import { deviceValueItemValidation } from './validators';
const Devices = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
const [size, setSize] = useState([0, 0]);
const [selectedDeviceValue, setSelectedDeviceValue] = useState<DeviceValue>();
const [onlyFav, setOnlyFav] = useState(false);
const [deviceValueDialogOpen, setDeviceValueDialogOpen] = useState(false);
const [showDeviceInfo, setShowDeviceInfo] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<number>();
const navigate = useNavigate();
useLayoutTitle(LL.DEVICES());
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), {
initialData: {
connected: true,
devices: []
}
});
const { data: deviceData, send: sendDeviceData } = useRequest(
(id: number) => readDeviceData(id),
{
initialData: {
nodes: []
},
immediate: false
}
);
const { loading: submitting, send: sendDeviceValue } = useRequest(
(data: { id: number; c: string; v: unknown }) => writeDeviceValue(data),
{
immediate: false
}
);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
const leftOffset = () => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) {
return 0;
}
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
return left + (right - left < 400 ? 0 : 200);
};
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
}
`,
Row: `
cursor: pointer;
background-color: #1E1E1E;
.td {
padding: 8px;
}
&.tr.tr-body.row-select.row-select-single-selected {
background-color: #177ac9;
font-weight: normal;
}
`
});
const device_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
`,
HeaderRow: `
.th {
padding: 8px;
height: 36px;
`,
Row: `
&:hover .td {
background-color: #177ac9;
`
}
]);
const data_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
height: auto;
max-height: 100%;
overflow-y: scroll;
::-webkit-scrollbar {
display:none;
}
`,
BaseRow: `
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
border-left: 1px solid #177ac9;
},
&:nth-of-type(2) {
text-align: right;
},
&:nth-of-type(3) {
border-right: 1px solid #177ac9;
}
`,
HeaderRow: `
.th {
border-top: 1px solid #565656;
}
`,
Row: `
&:nth-of-type(odd) .td {
background-color: #303030;
},
&:hover .td {
background-color: #177ac9;
}
`
}
]);
const ButtonTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} arrow classes={{ popper: className }} />
))(({ theme }) => ({
[`& .${tooltipClasses.arrow}`]: {
color: theme.palette.success.main
},
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: theme.palette.success.main,
color: 'rgba(0, 0, 0, 0.87)',
boxShadow: theme.shadows[1],
fontSize: 10
}
}));
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
if (state.sortKey === sortKey && !state.reverse) {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
};
const dv_sort = useSort(
{ nodes: deviceData.nodes },
{},
{
sortIcon: {
iconDefault: <UnfoldMoreOutlinedIcon />,
iconUp: <KeyboardArrowUpOutlinedIcon />,
iconDown: <KeyboardArrowDownOutlinedIcon />
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
NAME: (array) =>
array.sort((a, b) =>
a.id.toString().slice(2).localeCompare(b.id.toString().slice(2))
),
VALUE: (array) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
array.sort((a, b) => a.v.toString().localeCompare(b.v.toString()))
}
}
);
async function onSelectChange(action: Action, state: State) {
setSelectedDevice(state.id as number);
if (action.type === 'ADD_BY_ID_EXCLUSIVELY') {
await sendDeviceData(state.id as number);
}
}
const device_select = useRowSelect(
{ nodes: coreData.devices },
{
onChange: onSelectChange
}
);
const resetDeviceSelect = () => {
device_select.fns.onRemoveAll();
};
const escFunction = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (device_select) {
device_select.fns.onRemoveAll();
}
}
},
[device_select]
);
useEffect(() => {
document.addEventListener('keydown', escFunction);
return () => {
document.removeEventListener('keydown', escFunction);
};
}, [escFunction]);
const customize = () => {
if (selectedDevice === 99) {
navigate('/customentities');
} else {
navigate('/customizations', { state: selectedDevice });
}
};
const escapeCsvCell = (cell: string) => {
if (cell == null) {
return '';
}
const sc = cell.toString().trim();
if (sc === '' || sc === '""') {
return sc;
}
if (
sc.includes('"') ||
sc.includes(';') ||
sc.includes('\n') ||
sc.includes('\r')
) {
return '"' + sc.replace(/"/g, '""') + '"';
}
return sc;
};
const hasMask = (id: string, mask: number) =>
(parseInt(id.slice(0, 2), 16) & mask) === mask;
const handleDownloadCsv = () => {
const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
const filename =
coreData.devices[deviceIndex].tn + '_' + coreData.devices[deviceIndex].n;
const columns = [
{
accessor: (dv: DeviceValue) => dv.id.slice(2),
name: LL.ENTITY_NAME(0)
},
{
accessor: (dv: DeviceValue) =>
typeof dv.v === 'number' ? new Intl.NumberFormat().format(dv.v) : dv.v,
name: LL.VALUE(0)
},
{
accessor: (dv: DeviceValue) =>
dv.u !== undefined && DeviceValueUOM_s[dv.u]
? DeviceValueUOM_s[dv.u].replace(/[^a-zA-Z0-9]/g, '')
: '',
name: 'UoM'
},
{
accessor: (dv: DeviceValue) =>
dv.c && !hasMask(dv.id, DeviceEntityMask.DV_READONLY) ? 'yes' : 'no',
name: LL.WRITEABLE()
},
{
accessor: (dv: DeviceValue) =>
dv.h
? dv.h
: dv.l
? dv.l.join(' | ')
: dv.m !== undefined && dv.x !== undefined
? dv.m + ', ' + dv.x
: '',
name: 'Range'
}
];
const data = onlyFav
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
: deviceData.nodes;
const csvData = data.reduce(
(csvString: string, rowItem: DeviceValue) =>
csvString +
columns
.map(({ accessor }: { accessor: (dv: DeviceValue) => unknown }) =>
escapeCsvCell(accessor(rowItem) as string)
)
.join(';') +
'\r\n',
columns.map(({ name }: { name: string }) => escapeCsvCell(name)).join(';') +
'\r\n'
);
const downloadBlob = (blob: Blob) => {
const downloadLink = document.createElement('a');
downloadLink.download = filename;
downloadLink.href = window.URL.createObjectURL(blob);
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const device = { ...{ device: coreData.devices[deviceIndex] }, ...deviceData };
downloadBlob(
new Blob([JSON.stringify(device, null, 2)], {
type: 'text;charset:utf-8'
})
);
downloadBlob(new Blob([csvData], { type: 'text/csv;charset:utf-8' }));
};
useInterval(() => {
if (!deviceValueDialogOpen) {
selectedDevice ? void sendDeviceData(selectedDevice) : void sendCoreData();
}
}, 3000);
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
const id = Number(device_select.state.id);
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
.then(() => {
toast.success(LL.WRITE_CMD_SENT());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
setDeviceValueDialogOpen(false);
await sendDeviceData(id);
setSelectedDeviceValue(undefined);
});
};
const renderDeviceDetails = () => {
if (showDeviceInfo) {
const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
return (
<Dialog
sx={dialogStyle}
open={showDeviceInfo}
onClose={() => setShowDeviceInfo(false)}
>
<DialogTitle>{LL.DEVICE_DETAILS()}</DialogTitle>
<DialogContent dividers>
<List dense={true}>
<ListItem>
<ListItemText
primary={LL.TYPE(0)}
secondary={coreData.devices[deviceIndex].tn}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.NAME(0)}
secondary={coreData.devices[deviceIndex].n}
/>
</ListItem>
{coreData.devices[deviceIndex].t !== DeviceType.CUSTOM && (
<>
<ListItem>
<ListItemText
primary={LL.BRAND()}
secondary={coreData.devices[deviceIndex].b}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.ID_OF(LL.DEVICE())}
secondary={
'0x' +
(
'00' +
coreData.devices[deviceIndex].d.toString(16).toUpperCase()
).slice(-2)
}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.ID_OF(LL.PRODUCT())}
secondary={coreData.devices[deviceIndex].p}
/>
</ListItem>
<ListItem>
<ListItemText
primary={LL.VERSION()}
secondary={coreData.devices[deviceIndex].v}
/>
</ListItem>
</>
)}
</List>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
onClick={() => setShowDeviceInfo(false)}
color="secondary"
>
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
};
const renderCoreData = () => (
<>
<IconContext.Provider
value={{
color: 'lightblue',
size: '18',
style: { verticalAlign: 'middle' }
}}
>
{!coreData.connected && (
<MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />
)}
{coreData.connected && (
<Table
data={{ nodes: coreData.devices }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
>
{(tableList: Device[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
<HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.length === 0 && (
<CircularProgress sx={{ margin: 1 }} size={18} />
)}
{tableList.map((device: Device) => (
<Row key={device.id} item={device}>
<Cell>
<DeviceIcon type_id={device.t} />
&nbsp;&nbsp;
{device.n}
<span style={{ color: 'lightblue' }}>
&nbsp;&nbsp;({device.e})
</span>
</Cell>
<Cell stiff>{device.tn}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
)}
</IconContext.Provider>
</>
);
const deviceValueDialogClose = () => {
setDeviceValueDialogOpen(false);
void sendDeviceData(selectedDevice);
};
const renderDeviceData = () => {
if (!selectedDevice) {
return;
}
const showDeviceValue = (dv: DeviceValue) => {
setSelectedDeviceValue(dv);
setDeviceValueDialogOpen(true);
};
const renderNameCell = (dv: DeviceValue) => (
<>
{dv.id.slice(2)}&nbsp;
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
<StarIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</>
);
const shown_data = onlyFav
? deviceData.nodes.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE))
: deviceData.nodes;
const deviceIndex = coreData.devices.findIndex(
(d) => d.id === device_select.state.id
);
if (deviceIndex === -1) {
return;
}
return (
<Box
sx={{
backgroundColor: 'black',
position: 'absolute',
left: () => leftOffset(),
right: 0,
bottom: 0,
top: 64,
zIndex: 'modal',
maxHeight: () => size[1] - 126,
border: '1px solid #177ac9'
}}
>
<Box sx={{ border: '1px solid #177ac9' }}>
<Typography noWrap variant="subtitle1" color="warning.main" sx={{ ml: 1 }}>
{coreData.devices[deviceIndex].n}&nbsp;(
{coreData.devices[deviceIndex].tn})
</Typography>
<Grid container justifyContent="space-between">
<Typography sx={{ ml: 1 }} variant="subtitle2" color="grey">
{LL.SHOWING() +
' ' +
shown_data.length +
'/' +
coreData.devices[deviceIndex].e +
' ' +
LL.ENTITIES(shown_data.length)}
<ButtonTooltip title="Info">
<IconButton onClick={() => setShowDeviceInfo(true)}>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
{me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}>
<FormatListNumberedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
)}
<ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
<ButtonTooltip title={LL.FAVORITES()}>
<IconButton onClick={() => setOnlyFav(!onlyFav)}>
{onlyFav ? (
<StarIcon color="primary" sx={{ fontSize: 18 }} />
) : (
<StarBorderOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
)}
</IconButton>
</ButtonTooltip>
</Typography>
<Grid justifyContent="flex-end">
<ButtonTooltip title={LL.CANCEL()}>
<IconButton onClick={resetDeviceSelect}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton>
</ButtonTooltip>
</Grid>
</Grid>
</Box>
<Table
data={{ nodes: shown_data }}
theme={data_theme}
sort={dv_sort}
layout={{ custom: true, fixedHeader: true }}
>
{(tableList: DeviceValue[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(dv_sort.state, 'NAME')}
onClick={() => dv_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.ENTITY_NAME(0)}
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(dv_sort.state, 'VALUE')}
onClick={() => dv_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff />
</HeaderRow>
</Header>
<Body>
{tableList.map((dv: DeviceValue) => (
<Row key={dv.id} item={dv} onClick={() => showDeviceValue(dv)}>
<Cell>{renderNameCell(dv)}</Cell>
<Cell>{formatValue(LL, dv.v, dv.u)}</Cell>
<Cell stiff>
{me.admin &&
dv.c &&
!hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
<IconButton
size="small"
onClick={() => showDeviceValue(dv)}
>
{dv.v === '' ? (
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
) : (
<EditIcon color="primary" sx={{ fontSize: 16 }} />
)}
</IconButton>
)}
</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
</Box>
);
};
return (
<SectionContent id="devices-window">
{renderCoreData()}
{renderDeviceData()}
{renderDeviceDetails()}
{selectedDeviceValue && (
<DevicesDialog
open={deviceValueDialogOpen}
onClose={deviceValueDialogClose}
onSave={deviceValueDialogSave}
selectedItem={selectedDeviceValue}
writeable={
selectedDeviceValue.c !== undefined &&
!hasMask(selectedDeviceValue.id, DeviceEntityMask.DV_READONLY)
}
validator={deviceValueItemValidation(selectedDeviceValue)}
progress={submitting}
/>
)}
</SectionContent>
);
};
export default Devices;

View File

@@ -1,229 +0,0 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormHelperText,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { validate } from 'validators';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types';
interface DevicesDialogProps {
open: boolean;
onClose: () => void;
onSave: (as: DeviceValue) => void;
selectedItem: DeviceValue;
writeable: boolean;
validator: Schema;
progress: boolean;
}
const DevicesDialog = ({
open,
onClose,
onSave,
selectedItem,
writeable,
validator,
progress
}: DevicesDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const close = () => {
onClose();
};
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return LL.HOURS();
case DeviceValueUOM.MINUTES:
return LL.MINUTES();
case DeviceValueUOM.SECONDS:
return LL.SECONDS();
default:
return DeviceValueUOM_s[uom];
}
};
const showHelperText = (dv: DeviceValue) =>
dv.h ? (
dv.h
) : dv.l ? (
dv.l.join(' | ')
) : dv.m !== undefined && dv.x !== undefined ? (
<>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</>
) : undefined;
return (
<Dialog sx={dialogStyle} open={open} onClose={close}>
<DialogTitle>
{selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Box>
<Grid container>
<Grid size={12}>
{editItem.l ? (
<TextField
name="v"
label={LL.VALUE(0)}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
select
onChange={updateFormValue}
>
{editItem.l.map((val) => (
<MenuItem value={val} key={val}>
{val}
</MenuItem>
))}
</TextField>
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
<ValidatedTextField
fieldErrors={fieldErrors}
name="v"
label={LL.VALUE(0)}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus
disabled={!writeable}
type="number"
sx={{ width: '30ch' }}
onChange={updateFormValue}
slotProps={{
htmlInput: editItem.s
? { min: editItem.m, max: editItem.x, step: editItem.s }
: {},
input: {
startAdornment: (
<InputAdornment position="start">
{setUom(editItem.u)}
</InputAdornment>
)
}
}}
/>
) : (
<ValidatedTextField
fieldErrors={fieldErrors}
name="v"
label={LL.VALUE(0)}
value={editItem.v}
disabled={!writeable}
sx={{ width: '30ch' }}
multiline={!editItem.u}
onChange={updateFormValue}
/>
)}
</Grid>
{writeable && (
<Grid>
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
{writeable ? (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mx: 0.6
},
position: 'relative'
}}
>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"
>
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
</Button>
{progress && (
<CircularProgress
size={24}
sx={{
color: '#4caf50',
position: 'absolute',
right: '20%',
marginTop: '6px'
}}
/>
)}
</Box>
) : (
<Button variant="outlined" onClick={close} color="secondary">
{LL.CLOSE()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default DevicesDialog;

View File

@@ -1,103 +0,0 @@
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types';
import type { DeviceEntity } from './types';
interface EntityMaskToggleProps {
onUpdate: (de: DeviceEntity) => void;
de: DeviceEntity;
}
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const getMaskNumber = (newMask: string[]) => {
let new_mask = 0;
for (const entry of newMask) {
new_mask |= Number(entry);
}
return new_mask;
};
const getMaskString = (m: number) => {
const new_masks: string[] = [];
if ((m & 1) === 1) {
new_masks.push('1');
}
if ((m & 2) === 2) {
new_masks.push('2');
}
if ((m & 4) === 4) {
new_masks.push('4');
}
if ((m & 8) === 8) {
new_masks.push('8');
}
if ((m & 128) === 128) {
new_masks.push('128');
}
return new_masks;
};
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={getMaskString(de.m)}
onChange={(event, mask: string[]) => {
de.m = getMaskNumber(mask);
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
}
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(de);
}}
>
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
<OptionIcon
type="favorite"
isSet={
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
}
/>
</ToggleButton>
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
<OptionIcon
type="readonly"
isSet={
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
}
/>
</ToggleButton>
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
<OptionIcon
type="api_mqtt_exclude"
isSet={
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
DeviceEntityMask.DV_API_MQTT_EXCLUDE
}
/>
</ToggleButton>
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
<OptionIcon
type="web_exclude"
isSet={
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
DeviceEntityMask.DV_WEB_EXCLUDE
}
/>
</ToggleButton>
<ToggleButton value="128">
<OptionIcon
type="deleted"
isSet={
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
}
/>
</ToggleButton>
</ToggleButtonGroup>
);
};
export default EntityMaskToggle;

View File

@@ -1,191 +0,0 @@
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp';
import GitHubIcon from '@mui/icons-material/GitHub';
import MenuBookIcon from '@mui/icons-material/MenuBookTwoTone';
import {
Avatar,
Box,
Button,
Divider,
Link,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
Stack,
Typography
} from '@mui/material';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
import { API, callAction } from '../../api/app';
import type { APIcall } from './types';
const Help = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.HELP());
const { me } = useContext(AuthenticatedContext);
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null);
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
useRequest(() => callAction({ action: 'customSupport' })).onSuccess((event) => {
if (event && event.data && Object.keys(event.data).length !== 0) {
const data = event.data.Support;
if (data.img_url) {
setCustomSupportIMG(data.img_url);
}
if (data.html) {
setCustomSupportHTML(data.html.join('<br/>'));
}
}
});
// const { send: sendExportAllValues } = useRequest(
// () => callAction({ action: 'export', param: 'allvalues' }),
// {
// immediate: false
// }
// )
// .onSuccess((event) => {
// saveFile(event.data, 'allvalues', '.txt');
// toast.info(LL.DOWNLOAD_SUCCESSFUL());
// })
// .onError((error) => {
// toast.error(error.message);
// });
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
})
.onSuccess((event) => {
saveFile(event.data, 'system_info', '.json');
toast.info(LL.DOWNLOAD_SUCCESSFUL());
})
.onError((error) => {
toast.error(error.message);
});
return (
<SectionContent>
<Stack
padding={1}
mb={2}
direction="row"
divider={<Divider orientation="vertical" flexItem />}
sx={{
borderRadius: 3,
border: '2px solid grey',
justifyContent: 'space-evenly',
alignItems: 'center'
}}
>
<Typography variant="subtitle1">
{customSupportHTML ? (
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} />
) : (
LL.HELP_INFORMATION_5()
)}
</Typography>
<Box
component="img"
referrerPolicy="no-referrer"
sx={{
maxHeight: { xs: 100, md: 250 }
}}
onError={() => setNotFound(true)}
src={
notFound
? ''
: customSupportIMG || 'https://emsesp.org/_media/images/installer.jpeg'
}
/>
</Stack>
{me.admin && (
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListItem>
<ListItemButton component="a" href="https://emsesp.org">
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<MenuBookIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_1()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton component="a" href="https://discord.gg/3J3GgnzpyT">
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<CommentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_2()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<GitHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_3()} />
</ListItemButton>
</ListItem>
</List>
)}
<Box p={2} color="warning.main">
<Typography mb={1} variant="body1">
{LL.HELP_INFORMATION_4()}.
</Typography>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })}
>
{LL.SUPPORT_INFORMATION(0)}
</Button>
</Box>
{/* <Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportAllValues()}
>
{LL.DOWNLOAD(1)}&nbsp;{LL.ALLVALUES()}
</Button> */}
<Divider sx={{ mt: 4 }} />
<Typography color="white" variant="subtitle1" align="center" mt={1}>
&copy;&nbsp;
<Link target="_blank" href="https://emsesp.org" color="primary">
{'emsesp.org'}
</Link>
</Typography>
</SectionContent>
);
};
export default Help;

View File

@@ -1,269 +0,0 @@
import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app';
import ModulesDialog from './ModulesDialog';
import type { ModuleItem } from './types';
const Modules = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedModuleItem, setSelectedModuleItem] = useState<ModuleItem>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.MODULES());
const {
data: modules,
send: fetchModules,
error
} = useRequest(readModules, {
initialData: []
});
const { send: updateModules } = useRequest(
(data: { key: string; enabled: boolean; license: string }) => writeModules(data),
{
immediate: false
}
);
const modules_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:hover .td {
border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
`
});
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogSave = (updatedItem: ModuleItem) => {
setDialogOpen(false);
updateModuleItem(updatedItem);
};
const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi);
setDialogOpen(true);
}, []);
const onCancel = async () => {
await fetchModules().then(() => {
setNumChanges(0);
});
};
function hasModulesChanged(mi: ModuleItem) {
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
}
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
return new_data;
});
};
const saveModules = async () => {
await updateModules({
modules: modules.map((condensed_mi) => ({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
}))
})
.then(() => {
toast.success(LL.MODULES_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchModules();
setNumChanges(0);
});
};
const renderContent = () => {
if (!modules) {
return <FormLoader onRetry={fetchModules} errorMessage={error?.message} />;
}
if (modules.length === 0) {
return (
<Typography variant="body2" color="error">
{LL.MODULES_NONE()}
</Typography>
);
}
const colorStatus = (status: number) => {
if (status === 1) {
return <div style={{ color: 'red' }}>Pending Activation</div>;
}
return <div style={{ color: '#00FF7F' }}>Activated</div>;
};
return (
<>
<Box mb={2} color="warning.main">
<Typography variant="body1">{LL.MODULES_DESCRIPTION()}.</Typography>
</Box>
<Table
data={{ nodes: modules }}
theme={modules_theme}
layout={{ custom: true }}
>
{(tableList: ModuleItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell />
<HeaderCell>{LL.NAME(0)}</HeaderCell>
<HeaderCell>Author</HeaderCell>
<HeaderCell>{LL.VERSION()}</HeaderCell>
<HeaderCell>Message</HeaderCell>
<HeaderCell>{LL.STATUS_OF('')}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((mi: ModuleItem) => (
<Row key={mi.id} item={mi} onClick={() => editModuleItem(mi)}>
<Cell stiff>
{mi.enabled ? (
<CircleIcon
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
</Cell>
<Cell>{mi.name}</Cell>
<Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell>
<Cell>{colorStatus(mi.status)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
<Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveModules}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
</Box>
</>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()}
{selectedModuleItem && (
<ModulesDialog
open={dialogOpen}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedModuleItem}
/>
)}
</SectionContent>
);
};
export default Modules;

View File

@@ -1,106 +0,0 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import { BlockFormControlLabel } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import type { ModuleItem } from './types';
interface ModulesDialogProps {
open: boolean;
onClose: () => void;
onSave: (mi: ModuleItem) => void;
selectedItem: ModuleItem;
}
const ModulesDialog = ({
open,
onClose,
onSave,
selectedItem
}: ModulesDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const close = () => {
onClose();
};
const save = () => {
onSave(editItem);
};
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.enabled}
onChange={updateFormValue}
name="enabled"
/>
}
label="Enabled"
/>
</Grid>
<Box mt={2} mb={1}>
<TextField
name="license"
label="License Key"
multiline
rows={6}
fullWidth
value={editItem.license}
onChange={updateFormValue}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default ModulesDialog;

View File

@@ -1,372 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Divider, Stack, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
import { schedulerItemValidation } from './validators';
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedScheduleItem, setSelectedScheduleItem] = useState<ScheduleItem>();
const [dow, setDow] = useState<string[]>([]);
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.SCHEDULER());
const {
data: schedule,
send: fetchSchedule,
error
} = useRequest(readSchedule, {
initialData: []
});
const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
immediate: false
}
);
function hasScheduleChanged(si: ScheduleItem) {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
si.active !== si.o_active ||
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme({
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
});
const saveSchedule = async () => {
await updateSchedule({
schedule: schedule
.filter((si) => !si.deleted)
.map((condensed_si) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
})
.then(() => {
toast.success(LL.SCHEDULE_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchSchedule();
setNumChanges(0);
});
};
const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false);
setSelectedScheduleItem(si);
setDialogOpen(true);
if (si.o_name === undefined) {
si.o_name = si.name;
}
}, []);
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchSchedule().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data;
});
};
const addScheduleItem = () => {
setCreating(true);
setSelectedScheduleItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
setDialogOpen(true);
};
const renderSchedule = () => {
if (!schedule) {
return <FormLoader onRetry={fetchSchedule} errorMessage={error?.message} />;
}
const dayBox = (si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography
sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
const scheduleType = (si: ScheduleItem) => (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
</Typography>
</Box>
);
return (
<Table
data={{
nodes: schedule
.filter((si) => !si.deleted)
.sort((a, b) => a.flags - b.flags)
}}
theme={schedule_theme}
layout={{ custom: true }}
>
{(tableList: ScheduleItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((si: ScheduleItem) => (
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff>
{si.active ? (
<CircleIcon
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > 127 ? (
scheduleType(si)
) : (
<>
{dayBox(si, ScheduleFlag.SCHEDULE_MON)}
{dayBox(si, ScheduleFlag.SCHEDULE_TUE)}
{dayBox(si, ScheduleFlag.SCHEDULE_WED)}
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
</>
)}
</Stack>
</Cell>
<Cell>{si.time}</Cell>
<Cell>{si.cmd}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.name}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Box mb={2} color="warning.main">
<Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
</Box>
{renderSchedule()}
{selectedScheduleItem && (
<SettingsSchedulerDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow}
/>
)}
<Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveSchedule}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addScheduleItem}
>
{LL.ADD(0)}
</Button>
</ButtonRow>
</Box>
</Box>
</SectionContent>
);
};
export default Scheduler;

View File

@@ -1,401 +0,0 @@
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { validate } from 'validators';
import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ei: ScheduleItem) => void;
selectedItem: ScheduleItem;
validator: Schema;
dow: string[];
}
const SchedulerDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator,
dow
}: SchedulerDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// set the flags based on type when page is loaded...
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType(
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
);
}
}, [open, selectedItem]);
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const saveandactivate = async () => {
editItem.active = true;
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const getFlagDOWnumber = (newFlag: string[]) => {
let new_flag = 0;
for (const entry of newFlag) {
new_flag |= Number(entry);
}
return new_flag & 127;
};
const getFlagDOWstring = (f: number) => {
const new_flags: string[] = [];
if ((f & 129) === 1) {
new_flags.push('1');
}
if ((f & 130) === 2) {
new_flags.push('2');
}
if ((f & 4) === 4) {
new_flags.push('4');
}
if ((f & 8) === 8) {
new_flags.push('8');
}
if ((f & 16) === 16) {
new_flags.push('16');
}
if ((f & 32) === 32) {
new_flags.push('32');
}
if ((f & 64) === 64) {
new_flags.push('64');
}
return new_flags;
};
const showDOW = (si: ScheduleItem, flag: number) => (
<Typography
sx={{ fontSize: 10 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.SCHEDULE(1)}
</DialogTitle>
<DialogContent dividers>
<ToggleButtonGroup
size="small"
color="secondary"
value={scheduleType}
exclusive
disabled={!creating}
onChange={(_event, flag: ScheduleFlag) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
setEditItem({ ...editItem, time: '' });
// set the flags based on type
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setEditItem(
flag === ScheduleFlag.SCHEDULE_DAY
? { ...editItem, flags: 0 }
: { ...editItem, flags: flag }
);
}
}}
>
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography
sx={{ fontSize: 10 }}
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
>
{LL.SCHEDULE(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
}
>
{LL.TIMER(0)}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
}
>
{LL.ONCHANGE()}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
}
>
{LL.CONDITION()}
</Typography>
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: 10 }}
color={
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup>
{scheduleType === ScheduleFlag.SCHEDULE_DAY && (
<ToggleButtonGroup
size="small"
color="secondary"
value={getFlagDOWstring(editItem.flags)}
onChange={(_event, flag: string[]) => {
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
}}
>
<ToggleButton value="2">
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)}
</ToggleButton>
<ToggleButton value="4">
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
</ToggleButton>
<ToggleButton value="8">
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
</ToggleButton>
<ToggleButton value="16">
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
</ToggleButton>
<ToggleButton value="32">
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
</ToggleButton>
<ToggleButton value="64">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
</ToggleButton>
<ToggleButton value="1">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
</ToggleButton>
</ToggleButtonGroup>
)}
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && (
<>
<Grid container>
<BlockFormControlLabel
control={
<Checkbox
checked={editItem.active}
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
/>
</Grid>
<Grid container>
{scheduleType === ScheduleFlag.SCHEDULE_DAY ||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
<>
<TextField
name="time"
type="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_TIMER
? LL.TIMER(1)
: LL.TIME(1)
}
value={editItem.time === '' ? '00:00' : editItem.time}
margin="normal"
onChange={updateFormValue}
/>
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && (
<Box color="warning.main" ml={2} mt={4}>
<Typography variant="body2">
{LL.SCHEDULER_HELP_2()}
</Typography>
</Box>
)}
</>
) : (
<TextField
name="time"
label={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
? LL.CONDITION()
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
? LL.ONCHANGE()
: LL.IMMEDIATE()
}
multiline
fullWidth
value={editItem.time === '00:00' ? '' : editItem.time}
margin="normal"
onChange={updateFormValue}
/>
)}
</Grid>
</>
)}
<ValidatedTextField
fieldErrors={fieldErrors}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="name"
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
value={editItem.name}
fullWidth
margin="normal"
onChange={updateFormValue}
/>
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={saveandactivate}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default SchedulerDialog;

View File

@@ -1,507 +0,0 @@
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
import { Box, Button, Typography } from '@mui/material';
import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import {
readSensorData,
writeAnalogSensor,
writeTemperatureSensor
} from '../../api/app';
import DashboardSensorsAnalogDialog from './SensorsAnalogDialog';
import DashboardSensorsTemperatureDialog from './SensorsTemperatureDialog';
import {
AnalogType,
AnalogTypeNames,
DeviceValueUOM,
DeviceValueUOM_s
} from './types';
import type {
AnalogSensor,
TemperatureSensor,
WriteAnalogSensor,
WriteTemperatureSensor
} from './types';
import {
analogSensorItemValidation,
temperatureSensorItemValidation
} from './validators';
const Sensors = () => {
const { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
const [selectedTemperatureSensor, setSelectedTemperatureSensor] =
useState<TemperatureSensor>();
const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>();
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
const [creating, setCreating] = useState<boolean>(false);
const { data: sensorData, send: fetchSensorData } = useRequest(
() => readSensorData(),
{
initialData: {
ts: [],
as: [],
analog_enabled: false,
platform: 'ESP32'
}
}
);
const { send: sendTemperatureSensor } = useRequest(
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
{
immediate: false
}
);
const { send: sendAnalogSensor } = useRequest(
(data: WriteAnalogSensor) => writeAnalogSensor(data),
{
immediate: false
}
);
useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
}, 3000);
const common_theme = useTheme({
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
border-bottom: 1px solid #565656;
}
.th {
height: 36px;
}
`,
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
.td {
padding: 8px;
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`,
Cell: `
&:last-of-type {
text-align: right;
},
`
});
const temperature_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
`
}
]);
const analog_theme = useTheme([
common_theme,
{
Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
`
}
]);
const RenderTemperatureSensors = () => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
const getSortIcon = (state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />;
}
if (state.sortKey === sortKey && !state.reverse) {
return <KeyboardArrowUpOutlinedIcon />;
}
return <UnfoldMoreOutlinedIcon />;
};
const analog_sort = useSort(
{ nodes: sensorData.as },
{},
{
sortIcon: {
iconDefault: <UnfoldMoreOutlinedIcon />,
iconUp: <KeyboardArrowUpOutlinedIcon />,
iconDown: <KeyboardArrowDownOutlinedIcon />
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
TYPE: (array) => array.sort((a, b) => a.t - b.t),
VALUE: (array) => array.sort((a, b) => a.v - b.v)
}
}
);
const temperature_sort = useSort(
{ nodes: sensorData.ts },
{},
{
sortIcon: {
iconDefault: <UnfoldMoreOutlinedIcon />,
iconUp: <KeyboardArrowUpOutlinedIcon />,
iconDown: <KeyboardArrowDownOutlinedIcon />
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
VALUE: (array) => array.sort((a, b) => a.t - b.t)
}
}
);
useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes });
}
return formatted;
};
function formatValue(value: unknown, uom: DeviceValueUOM) {
if (value === undefined) {
return '';
}
if (typeof value !== 'number') {
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
}
const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true);
}
};
const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false);
void fetchSensorData();
};
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
.then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined);
void fetchSensorData();
});
};
const updateAnalogSensor = (as: AnalogSensor) => {
if (me.admin) {
setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as);
setAnalogDialogOpen(true);
}
};
const onAnalogDialogClose = () => {
setAnalogDialogOpen(false);
void fetchSensorData();
};
const addAnalogSensor = () => {
setCreating(true);
setSelectedAnalogSensor({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
n: '',
g: 21, // default GPIO 21 which is safe for all platforms
u: 0,
v: 0,
o: 0,
t: 0,
f: 1,
d: false,
o_n: ''
});
setAnalogDialogOpen(true);
};
const onAnalogDialogSave = async (as: AnalogSensor) => {
await sendAnalogSensor({
id: as.id,
gpio: as.g,
name: as.n,
offset: as.o,
factor: as.f,
uom: as.u,
type: as.t,
deleted: as.d
})
.then(() => {
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
})
.catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
})
.finally(() => {
setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined);
void fetchSensorData();
});
};
const RenderAnalogSensors = () => (
<Table
data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
GPIO
</Button>
</HeaderCell>
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
{LL.TYPE(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((a: AnalogSensor) => (
<Row key={a.id} item={a} onClick={() => updateAnalogSensor(a)}>
<Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
{a.t === AnalogType.DIGITAL_OUT || a.t === AnalogType.DIGITAL_IN ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
)}
</Row>
))}
</Body>
</>
)}
</Table>
);
return (
<SectionContent>
<Typography sx={{ pb: 1 }} variant="h6" color="secondary">
{LL.TEMP_SENSORS()}
</Typography>
<RenderTemperatureSensors />
{selectedTemperatureSensor && (
<DashboardSensorsTemperatureDialog
open={temperatureDialogOpen}
onClose={onTemperatureDialogClose}
onSave={onTemperatureDialogSave}
selectedItem={selectedTemperatureSensor}
validator={temperatureSensorItemValidation(
sensorData.ts,
selectedTemperatureSensor
)}
/>
)}
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
{LL.ANALOG_SENSORS()}
</Typography>
<RenderAnalogSensors />
{selectedAnalogSensor && (
<DashboardSensorsAnalogDialog
open={analogDialogOpen}
onClose={onAnalogDialogClose}
onSave={onAnalogDialogSave}
creating={creating}
selectedItem={selectedAnalogSensor}
validator={analogSensorItemValidation(
sensorData.as,
selectedAnalogSensor,
creating,
sensorData.platform
)}
/>
)}
{sensorData?.analog_enabled === true && me.admin && (
<Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
<Button
variant="outlined"
color="primary"
startIcon={<AddCircleOutlineOutlinedIcon />}
onClick={addAnalogSensor}
>
{LL.ADD(0)}
</Button>
</Box>
)}
</SectionContent>
);
};
export default Sensors;

View File

@@ -1,353 +0,0 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { validate } from 'validators';
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types';
interface DashboardSensorsAnalogDialogProps {
open: boolean;
onClose: () => void;
onSave: (as: AnalogSensor) => void;
creating: boolean;
selectedItem: AnalogSensor;
validator: Schema;
}
const SensorsAnalogDialog = ({
open,
onClose,
onSave,
creating,
selectedItem,
validator
}: DashboardSensorsAnalogDialogProps) => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const remove = () => {
editItem.d = true;
onSave(editItem);
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="g"
label="GPIO"
sx={{ width: '11ch' }}
value={numberValue(editItem.g)}
type="number"
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
{creating && (
<Grid>
<Box color="warning.main" mt={2}>
<Typography variant="body2">{LL.WARN_GPIO()}</Typography>
</Box>
</Grid>
)}
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="n"
label={LL.NAME(0)}
value={editItem.n}
fullWidth
variant="outlined"
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
name="t"
label={LL.TYPE(0)}
value={editItem.t}
fullWidth
select
onChange={updateFormValue}
>
{AnalogTypeNames.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid>
<TextField
name="u"
label={LL.UNIT()}
value={editItem.u}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
>
{DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField>
</Grid>
)}
{editItem.t === AnalogType.ADC && (
<Grid>
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">mV</InputAdornment>
)
},
htmlInput: { min: '0', max: '3300', step: '1' }
}}
/>
</Grid>
)}
{editItem.t === AnalogType.COUNTER && (
<Grid>
<TextField
name="o"
label={LL.STARTVALUE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid>
<TextField
name="f"
label={LL.FACTOR()}
value={numberValue(editItem.f)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
htmlInput: { step: '0.001' }
}}
/>
</Grid>
)}
{editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26) && (
<Grid>
<TextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/>
</Grid>
)}
{editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26 && (
<>
<Grid>
<TextField
name="o"
label={LL.VALUE(0)}
value={numberValue(editItem.o)}
select
variant="outlined"
onChange={updateFormValue}
>
<MenuItem value={0}>{LL.OFF()}</MenuItem>
<MenuItem value={1}>{LL.ON()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="f"
label={LL.POLARITY()}
value={editItem.f}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
>
<MenuItem value={1}>{LL.ACTIVEHIGH()}</MenuItem>
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="u"
label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u}
select
onChange={updateFormValue}
>
<MenuItem value={0}>{LL.UNCHANGED()}</MenuItem>
<MenuItem value={1}>
{LL.ALWAYS()}&nbsp;{LL.OFF()}
</MenuItem>
<MenuItem value={2}>
{LL.ALWAYS()}&nbsp;{LL.ON()}
</MenuItem>
</TextField>
</Grid>
</>
)}
{(editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2) && (
<>
<Grid>
<TextField
name="f"
label={LL.FREQ()}
value={numberValue(editItem.f)}
type="number"
variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">Hz</InputAdornment>
)
},
htmlInput: { min: '1', max: '5000', step: '1' }
}}
/>
</Grid>
<Grid>
<TextField
name="o"
label={LL.DUTY_CYCLE()}
value={numberValue(editItem.o)}
type="number"
sx={{ width: '11ch' }}
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">%</InputAdornment>
)
},
htmlInput: { min: '0', max: '100', step: '0.1' }
}}
/>
</Grid>
</>
)}
</Grid>
</DialogContent>
<DialogActions>
{!creating && (
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default SensorsAnalogDialog;

View File

@@ -1,136 +0,0 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { validate } from 'validators';
import type { TemperatureSensor } from './types';
interface SensorsTemperatureDialogProps {
open: boolean;
onClose: () => void;
onSave: (ts: TemperatureSensor) => void;
selectedItem: TemperatureSensor;
validator: Schema;
}
const SensorsTemperatureDialog = ({
open,
onClose,
onSave,
selectedItem,
validator
}: SensorsTemperatureDialogProps) => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
};
const save = async () => {
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography>
</Box>
<Grid container spacing={2}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="n"
label={LL.NAME(0)}
value={editItem.n}
sx={{ width: '30ch' }}
onChange={updateFormValue}
/>
</Grid>
<Grid>
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default SensorsTemperatureDialog;

View File

@@ -1,62 +0,0 @@
import type { TranslationFunctions } from 'i18n/i18n-types';
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days });
}
if (hours) {
if (formatted) formatted += ' ';
formatted += LL.NUM_HOURS({ num: hours });
}
if (minutes) {
if (formatted) formatted += ' ';
formatted += LL.NUM_MINUTES({ num: minutes });
}
return formatted;
};
export function formatValue(
LL: TranslationFunctions,
value?: unknown,
uom?: DeviceValueUOM
) {
if (typeof value !== 'number' || uom === undefined || value === undefined) {
if (value === undefined || typeof value === 'boolean') {
return '';
}
return value as string;
}
switch (uom) {
case DeviceValueUOM.HOURS:
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
case DeviceValueUOM.MINUTES:
return value ? formatDurationMin(LL, value) : LL.NUM_MINUTES({ num: 0 });
case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value);
case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT:
return (
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
}
}

View File

@@ -1,446 +0,0 @@
export interface Settings {
locale: string;
tx_mode: number;
ems_bus_id: number;
syslog_enabled: boolean;
syslog_level: number;
syslog_mark_interval: number;
syslog_host: string;
syslog_port: number;
boiler_heatingoff: boolean;
remote_timeout_en: boolean;
remote_timeout: number;
shower_timer: boolean;
shower_alert: boolean;
shower_alert_coldshot: number;
shower_alert_trigger: number;
shower_min_duration: number;
rx_gpio: number;
tx_gpio: number;
telnet_enabled: boolean;
dallas_gpio: number;
dallas_parasite: boolean;
led_gpio: number;
hide_led: boolean;
low_clock: boolean;
notoken_api: boolean;
readonly_mode: boolean;
analog_enabled: boolean;
pbutton_gpio: number;
trace_raw: boolean;
board_profile: string;
bool_format: number;
bool_dashboard: number;
enum_format: number;
fahrenheit: boolean;
phy_type: number;
eth_power: number;
eth_phy_addr: number;
eth_clock_mode: number;
platform: string;
modbus_enabled: boolean;
modbus_port: number;
modbus_max_clients: number;
modbus_timeout: number;
developer_mode: boolean;
}
export enum busConnectionStatus {
BUS_STATUS_CONNECTED = 0,
BUS_STATUS_TX_ERRORS = 1,
BUS_STATUS_OFFLINE = 2
}
export interface Stat {
id: number; // id
s: number; // success
f: number; // fail
q: number; // quality
}
export interface Activity {
stats: Stat[];
}
export interface Device {
id: number; // id index
tn: string; // device type translated name
t: number; // device type id
b: string; // brand
n: string; // name
d: number; // deviceid
p: number; // productid
v: string; // version
e: number; // entities
url?: string; // lowercase type name used in API URL
}
export interface TemperatureSensor {
id: string; // id string
n: string; // name/alias
t?: number; // temp, optional
o: number; // offset
u: number; // uom
o_n?: string;
}
export interface AnalogSensor {
id: number;
g: number; // GPIO
n: string;
v: number;
u: number;
o: number;
f: number;
t: number;
d: boolean; // deleted flag
o_n?: string;
}
export interface WriteTemperatureSensor {
id: string;
name: string;
offset: number;
}
export interface SensorData {
ts: TemperatureSensor[];
as: AnalogSensor[];
analog_enabled: boolean;
platform: string;
}
export interface CoreData {
connected: boolean;
devices: Device[];
}
export interface DashboardItem {
id: number; // unique index
t?: number; // type from DeviceType
n?: string; // name, optional
dv?: DeviceValue; // device value, optional
nodes?: DashboardItem[]; // children nodes, optional
}
export interface DeviceValue {
id: string; // index, contains mask+name
v?: unknown; // value, Number, String or Boolean - can be undefined
u?: number; // uom, optional
c?: string; // command, optional
l?: string[]; // list, optional
h?: string; // help text, optional
s?: string; // steps for up/down, optional
m?: number; // min, optional
x?: number; // max, optional
}
export interface DeviceData {
nodes: DeviceValue[];
}
export interface DeviceEntity {
id: string; // shortname
v?: unknown; // value, in any format, optional
n?: string; // fullname, optional
cn?: string; // custom fullname, optional
t?: string; // tag for name
m: DeviceEntityMask; // mask
w: boolean; // writeable
mi?: number; // min value
ma?: number; // max value
o_m?: number; // original mask before edits
o_cn?: string; // original cn before edits
o_mi?: number; // original min value
o_ma?: number; // original max value
}
export enum DeviceValueUOM {
NONE = 0,
DEGREES,
DEGREES_R,
PERCENT,
LMIN,
KWH,
WH,
HOURS,
MINUTES,
UA,
BAR,
KW,
W,
KB,
SECONDS,
DBM,
FAHRENHEIT,
MV,
SQM,
M3,
L,
KMIN,
K,
VOLTS,
MBAR,
LH
}
export const DeviceValueUOM_s = [
'',
'°C',
'°C',
'%',
'l/min',
'kWh',
'Wh',
'hours',
'minutes',
'µA',
'bar',
'kW',
'W',
'KB',
'seconds',
'dBm',
'°F',
'mV',
'm²',
'm³',
'l',
'K*min',
'K',
'V',
'mbar',
'l/h'
];
export enum AnalogType {
REMOVED = -1,
NOTUSED = 0,
DIGITAL_IN = 1,
COUNTER = 2,
ADC = 3,
TIMER = 4,
RATE = 5,
DIGITAL_OUT = 6,
PWM_0 = 7,
PWM_1 = 8,
PWM_2 = 9
}
export const AnalogTypeNames = [
'(disabled)',
'Digital In',
'Counter',
'ADC',
'Timer',
'Rate',
'Digital Out',
'PWM 0',
'PWM 1',
'PWM 2'
];
type BoardProfiles = Record<string, string>;
export const BOARD_PROFILES: BoardProfiles = {
S32: 'BBQKees Gateway S32',
S32S3: 'BBQKees Gateway S3',
E32: 'BBQKees Gateway E32',
E32V2: 'BBQKees Gateway E32 V2',
NODEMCU: 'NodeMCU 32S',
'MH-ET': 'MH-ET Live D1 Mini',
LOLIN: 'Lolin D32',
OLIMEX: 'Olimex ESP32-EVB',
OLIMEXPOE: 'Olimex ESP32-POE',
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
};
export interface BoardProfile {
board_profile: string;
led_gpio: number;
dallas_gpio: number;
rx_gpio: number;
tx_gpio: number;
pbutton_gpio: number;
phy_type: number;
eth_power: number;
eth_phy_addr: number;
eth_clock_mode: number;
}
export interface APIcall {
device: string;
cmd: string;
id: number;
data?: string; // optional
}
export interface Action {
action: string;
param?: string; // optional
}
export interface WriteAnalogSensor {
id: number;
gpio: number;
name: string;
factor: number;
offset: number;
uom: number;
type: number;
deleted: boolean;
}
export enum DeviceEntityMask {
DV_DEFAULT = 0,
DV_WEB_EXCLUDE = 1,
DV_API_MQTT_EXCLUDE = 2,
DV_READONLY = 4,
DV_FAVORITE = 8,
DV_DELETED = 128
}
export interface ScheduleItem {
id: number; // unique index
active: boolean;
deleted?: boolean;
flags: number;
time: string; // also used for Condition and On Change
cmd: string;
value: string;
name: string; // can be empty
o_id?: number;
o_active?: boolean;
o_deleted?: boolean;
o_flags?: number;
o_time?: string;
o_cmd?: string;
o_value?: string;
o_name?: string;
}
export interface Schedule {
schedule: ScheduleItem[];
}
export interface ModuleItem {
id: number; // unique index
key: string;
name: string;
author: string;
version: string;
status: number;
message: string;
enabled: boolean;
license: string;
o_enabled?: boolean;
o_license?: string;
}
export interface Modules {
modules: ModuleItem[];
}
export enum ScheduleFlag {
SCHEDULE_SUN = 1,
SCHEDULE_MON = 2,
SCHEDULE_TUE = 4,
SCHEDULE_WED = 8,
SCHEDULE_THU = 16,
SCHEDULE_FRI = 32,
SCHEDULE_SAT = 64,
// types...
SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130, // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
}
export interface EntityItem {
id: number; // unique number
ram: number;
name: string;
device_id: number | string;
type_id: number | string;
offset: number;
factor: number;
uom: number;
value_type: number;
value?: unknown;
writeable: boolean;
deleted?: boolean;
o_id?: number;
o_ram?: number;
o_name?: string;
o_device_id?: number | string;
o_type_id?: number | string;
o_offset?: number;
o_factor?: number;
o_uom?: number;
o_value_type?: number;
o_deleted?: boolean;
o_writeable?: boolean;
o_value?: unknown;
}
export interface Entities {
entities: EntityItem[];
}
// matches emsdevice.h DeviceType
export const enum DeviceType {
SYSTEM = 0,
TEMPERATURESENSOR = 1,
ANALOGSENSOR = 2,
SCHEDULER = 3,
CUSTOM = 4,
BOILER,
THERMOSTAT,
MIXER,
SOLAR,
HEATPUMP,
GATEWAY,
SWITCH,
CONTROLLER,
CONNECT,
ALERT,
EXTENSION,
GENERIC,
HEATSOURCE,
VENTILATION,
WATER,
POOL,
UNKNOWN
}
// matches emsdevicevalue.h
export const enum DeviceValueType {
BOOL,
INT8,
UINT8,
INT16,
UINT16,
UINT24,
TIME, // same as UINT24
UINT32,
ENUM,
STRING, // RAW
CMD
}
export const DeviceValueTypeNames = [
'BOOL',
'INT8',
'UINT8',
'INT16',
'UINT16',
'UINT24',
'TIME',
'UINT32',
'ENUM',
'RAW',
'CMD'
];

View File

@@ -1,504 +0,0 @@
import Schema from 'async-validator';
import type { InternalRuleItem } from 'async-validator';
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type {
AnalogSensor,
DeviceValue,
EntityItem,
ScheduleItem,
Settings,
TemperatureSensor
} from './types';
export const GPIO_VALIDATOR = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORR = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
(value >= 16 && value <= 17) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORC3 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS2 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 32) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS3 = {
validator(
rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 37) ||
(value >= 39 && value <= 42) ||
value > 48 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const createSettingsValidator = (settings: Settings) =>
new Schema({
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATOR
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATOR
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATOR
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATOR
],
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32C3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORC3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORC3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORC3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORC3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORC3
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S2' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS2
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS2
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS2
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS2
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS2
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS3
]
}),
...(settings.syslog_enabled && {
syslog_host: [
{ required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR
],
syslog_port: [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
syslog_mark_interval: [
{ required: true, message: 'Mark interval is required' },
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
]
}),
...(settings.modbus_enabled && {
modbus_max_clients: [
{ required: true, message: 'Max clients is required' },
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
],
modbus_port: [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
],
modbus_timeout: [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: 100,
max: 20000,
message: 'Must be between 100 and 20000'
}
]
}),
...(settings.shower_timer && {
shower_min_duration: [
{
type: 'number',
min: 10,
max: 360,
message: 'Time must be between 10 and 360 seconds'
}
]
}),
...(settings.shower_alert && {
shower_alert_trigger: [
{
type: 'number',
min: 1,
max: 20,
message: 'Time must be between 1 and 20 minutes'
}
],
shower_alert_coldshot: [
{
type: 'number',
min: 1,
max: 10,
message: 'Time must be between 1 and 10 seconds'
}
]
}),
...(settings.remote_timeout_en && {
remote_timeout: [
{
type: 'number',
min: 1,
max: 240,
message: 'Timeout must be between 1 and 240 hours'
}
]
})
});
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
validator(
rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (
name !== '' &&
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const schedulerItemValidation = (
schedule: ScheduleItem[],
scheduleItem: ScheduleItem
) =>
new Schema({
name: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: 1,
max: 300,
message: 'Command must be 1-300 characters'
}
]
});
export const uniqueCustomNameValidator = (
entity: EntityItem[],
o_name?: string
) => ({
validator(
rule: InternalRuleItem,
name: string,
callback: (error?: string) => void
) {
if (
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{1,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
],
device_id: [
{
validator(
rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
type_id: [
{
validator(
rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
offset: [
{ required: true, message: 'Offset is required' },
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
],
factor: [
{ required: true, message: 'Bytes is required' },
{ type: 'number', min: 1, max: 255, message: 'Must be between 1 and 255' }
]
});
export const uniqueTemperatureNameValidator = (
sensors: TemperatureSensor[],
o_name?: string
) => ({
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const temperatureSensorItemValidation = (
sensors: TemperatureSensor[],
sensor: TemperatureSensor
) =>
new Schema({
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
]
});
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
validator(
rule: InternalRuleItem,
gpio: number,
callback: (error?: string) => void
) {
if (sensors.find((as) => as.g === gpio)) {
callback('GPIO already in use');
} else {
callback();
}
}
});
export const uniqueAnalogNameValidator = (
sensors: AnalogSensor[],
o_name?: string
) => ({
validator(rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const analogSensorItemValidation = (
sensors: AnalogSensor[],
sensor: AnalogSensor,
creating: boolean,
platform: string
) =>
new Schema({
n: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
],
g: [
{ required: true, message: 'GPIO is required' },
platform === 'ESP32S3'
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR,
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
]
});
export const deviceValueItemValidation = (dv: DeviceValue) =>
new Schema({
v: [
{ required: true, message: 'Value is required' },
{
validator(
rule: InternalRuleItem,
value: unknown,
callback: (error?: string) => void
) {
if (
typeof value === 'number' &&
dv.m &&
dv.x &&
(value < dv.m || value > dv.x)
) {
callback('Value out of range');
}
callback();
}
}
]
});

View File

@@ -1,861 +0,0 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Divider,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { readSystemStatus } from 'api/system';
import { useRequest } from 'alova/client';
import RestartMonitor from 'app/status/RestartMonitor';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
ButtonRow,
FormLoader,
MessageBox,
SectionContent,
ValidatedTextField,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { validate } from 'validators';
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
import { BOARD_PROFILES } from '../main/types';
import type { APIcall, Settings } from '../main/types';
import { createSettingsValidator } from '../main/validators';
export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}>
{BOARD_PROFILES[code]}
</MenuItem>
));
}
const ApplicationSettings = () => {
const { data: hardwareData } = useRequest(readSystemStatus);
const {
loadData,
saveData,
updateDataValue,
data,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
errorMessage,
restartNeeded
} = useRest<Settings>({
read: readSettings,
update: writeSettings
});
const [restarting, setRestarting] = useState<boolean>();
const { LL } = useI18nContext();
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const { loading: processingBoard, send: readBoardProfile } = useRequest(
(boardProfile: string) => getBoardProfile(boardProfile),
{
immediate: false
}
).onSuccess((event) => {
const response = event.data as Settings;
updateDataValue({
...data,
board_profile: response.board_profile,
led_gpio: response.led_gpio,
dallas_gpio: response.dallas_gpio,
rx_gpio: response.rx_gpio,
tx_gpio: response.tx_gpio,
pbutton_gpio: response.pbutton_gpio,
phy_type: response.phy_type,
eth_power: response.eth_power,
eth_phy_addr: response.eth_phy_addr,
eth_clock_mode: response.eth_clock_mode
});
});
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const updateBoardProfile = async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message);
});
};
useLayoutTitle(LL.SETTINGS_OF(LL.APPLICATION()));
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MilliSecondsInputProps = {
endAdornment: <InputAdornment position="end">ms</InputAdornment>
};
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createSettingsValidator(data), data);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
} finally {
await saveData();
}
};
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value;
updateFormValue(event);
if (boardProfile === 'CUSTOM') {
updateDataValue({
...data,
board_profile: boardProfile
});
} else {
void updateBoardProfile(boardProfile);
}
};
const restart = async () => {
await validateAndSubmit();
await doRestart();
};
return (
<>
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.SERVICES()}
</Typography>
<Typography color="secondary">API</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.notoken_api}
onChange={updateFormValue}
name="notoken_api"
/>
}
label={LL.BYPASS_TOKEN()}
/>
<Typography color="secondary">Console</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.telnet_enabled}
onChange={updateFormValue}
name="telnet_enabled"
/>
}
label={LL.ENABLE_TELNET()}
/>
<Typography color="secondary">Modbus</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.modbus_enabled}
onChange={updateFormValue}
name="modbus_enabled"
disabled={!hardwareData.psram}
/>
}
label={LL.ENABLE_MODBUS()}
/>
{data.modbus_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="modbus_max_clients"
label={LL.AP_MAX_CLIENTS()}
variant="outlined"
value={numberValue(data.modbus_max_clients)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="modbus_port"
label="Port"
variant="outlined"
value={numberValue(data.modbus_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="modbus_timeout"
label="Timeout"
slotProps={{
input: MilliSecondsInputProps
}}
variant="outlined"
value={numberValue(data.modbus_timeout)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
)}
<Typography color="secondary">Syslog</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.syslog_enabled}
onChange={updateFormValue}
name="syslog_enabled"
/>
}
label={LL.ENABLE_SYSLOG()}
/>
{data.syslog_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="syslog_host"
label="Host"
variant="outlined"
value={data.syslog_host}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="syslog_port"
label="Port"
variant="outlined"
value={numberValue(data.syslog_port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="syslog_level"
label={LL.LOG_LEVEL()}
value={data.syslog_level}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERR</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</TextField>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="syslog_mark_interval"
label={LL.MARK_INTERVAL()}
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.syslog_mark_interval)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SENSORS()}
</Typography>
<Typography color="secondary">Analog</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.analog_enabled}
onChange={updateFormValue}
name="analog_enabled"
/>
}
label={LL.ENABLE_ANALOG()}
/>
{data.dallas_gpio !== 0 && (
<>
<Typography color="secondary">{LL.TEMPERATURE()}</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.dallas_parasite}
onChange={updateFormValue}
name="dallas_parasite"
/>
}
label={LL.ENABLE_PARASITE()}
/>
</>
)}
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.FORMATTING_OPTIONS()}
</Typography>
<Grid container spacing={2}>
<Grid size={3}>
<TextField
name="locale"
label={LL.LANGUAGE_ENTITIES()}
value={data.locale}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value="cz">Česky (CZ)</MenuItem>
<MenuItem value="de">Deutsch (DE)</MenuItem>
<MenuItem value="en">English (EN)</MenuItem>
<MenuItem value="fr">Français (FR)</MenuItem>
<MenuItem value="it">Italiano (IT)</MenuItem>
<MenuItem value="nl">Nederlands (NL)</MenuItem>
<MenuItem value="no">Norsk (NO)</MenuItem>
<MenuItem value="pl">Polski (PL)</MenuItem>
<MenuItem value="sk">Slovenčina (SK)</MenuItem>
<MenuItem value="sv">Svenska (SV)</MenuItem>
<MenuItem value="tr">Türk (TR)</MenuItem>
</TextField>
</Grid>
<Grid size={3}>
<TextField
name="bool_dashboard"
label={LL.BOOLEAN_FORMAT_DASHBOARD()}
value={data.bool_dashboard}
variant="outlined"
fullWidth
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={1}>{LL.ONOFF()}</MenuItem>
<MenuItem value={2}>{LL.ONOFF_CAP()}</MenuItem>
<MenuItem value={3}>true/false</MenuItem>
<MenuItem value={5}>1/0</MenuItem>
</TextField>
</Grid>
<Grid size={3}>
<TextField
name="bool_format"
label={LL.BOOLEAN_FORMAT_API()}
value={data.bool_format}
variant="outlined"
fullWidth
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={1}>{LL.ONOFF()}</MenuItem>
<MenuItem value={2}>{LL.ONOFF_CAP()}</MenuItem>
<MenuItem value={3}>&quot;true&quot;/&quot;false&quot;</MenuItem>
<MenuItem value={4}>true/false</MenuItem>
<MenuItem value={5}>&quot;1&quot;/&quot;0&quot;</MenuItem>
<MenuItem value={6}>1/0</MenuItem>
</TextField>
</Grid>
<Grid size={3}>
<TextField
name="enum_format"
label={LL.ENUM_FORMAT()}
value={data.enum_format}
variant="outlined"
fullWidth
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={1}>{LL.VALUE(5)}</MenuItem>
<MenuItem value={2}>{LL.INDEX()}</MenuItem>
</TextField>
</Grid>
</Grid>
<BlockFormControlLabel
control={
<Checkbox
checked={data.fahrenheit}
onChange={updateFormValue}
name="fahrenheit"
/>
}
label={LL.CONVERT_FAHRENHEIT()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.trace_raw}
onChange={updateFormValue}
name="trace_raw"
/>
}
label={LL.LOG_HEX()}
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SETTINGS_OF(LL.HARDWARE())}
</Typography>
<TextField
name="board_profile"
label={LL.BOARD_PROFILE()}
value={data.board_profile}
disabled={processingBoard || hardwareData.model.startsWith('BBQKees')}
variant="outlined"
onChange={changeBoardProfile}
margin="normal"
select
>
{boardProfileSelectItems()}
<Divider />
<MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip;
</MenuItem>
</TextField>
{data.board_profile === 'CUSTOM' && (
<>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="rx_gpio"
label={LL.GPIO_OF('Rx')}
fullWidth
variant="outlined"
value={numberValue(data.rx_gpio)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="tx_gpio"
label={LL.GPIO_OF('Tx')}
fullWidth
variant="outlined"
value={numberValue(data.tx_gpio)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="pbutton_gpio"
label={LL.GPIO_OF(LL.BUTTON())}
fullWidth
variant="outlined"
value={numberValue(data.pbutton_gpio)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="dallas_gpio"
label={
LL.GPIO_OF(LL.TEMPERATURE()) + ' (0=' + LL.DISABLED(1) + ')'
}
fullWidth
variant="outlined"
value={numberValue(data.dallas_gpio)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="led_gpio"
label={LL.GPIO_OF('LED') + ' (0=' + LL.DISABLED(1) + ')'}
fullWidth
variant="outlined"
value={numberValue(data.led_gpio)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="phy_type"
label={LL.PHY_TYPE()}
value={data.phy_type}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.DISABLED(1)}</MenuItem>
<MenuItem value={1}>LAN8720</MenuItem>
<MenuItem value={2}>TLK110</MenuItem>
</TextField>
</Grid>
</Grid>
{data.phy_type !== 0 && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<TextField
name="eth_power"
label={LL.GPIO_OF('PHY Power') + ' (-1=' + LL.DISABLED(1) + ')'}
fullWidth
variant="outlined"
value={numberValue(data.eth_power)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="eth_phy_addr"
label={LL.ADDRESS_OF('PHY I²C')}
fullWidth
variant="outlined"
value={numberValue(data.eth_phy_addr)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="eth_clock_mode"
label="PHY Clk"
value={data.eth_clock_mode}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>GPIO0_IN</MenuItem>
<MenuItem value={1}>GPIO0_OUT</MenuItem>
<MenuItem value={2}>GPIO16_OUT</MenuItem>
<MenuItem value={3}>GPIO17_OUT</MenuItem>
</TextField>
</Grid>
</Grid>
)}
</>
)}
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<TextField
name="tx_mode"
label={LL.TX_MODE()}
value={data.tx_mode}
variant="outlined"
onChange={updateFormValue}
margin="normal"
sx={{ width: '15ch' }}
select
>
<MenuItem value={1}>EMS</MenuItem>
<MenuItem value={2}>EMS+</MenuItem>
<MenuItem value={3}>HT3</MenuItem>
<MenuItem value={4}>{LL.HARDWARE()}</MenuItem>
</TextField>
</Grid>
<Grid>
<TextField
name="ems_bus_id"
label={LL.ID_OF(LL.EMS_BUS(0))}
value={data.ems_bus_id}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0x0a}>Terminal (0x0A)</MenuItem>
<MenuItem value={0x0b}>Service Key (0x0B)</MenuItem>
<MenuItem value={0x0d}>Modem (0x0D)</MenuItem>
<MenuItem value={0x0e}>Converter (0x0E)</MenuItem>
<MenuItem value={0x0f}>Time Module (0x0F)</MenuItem>
<MenuItem value={0x48}>Gateway 1 (0x48)</MenuItem>
<MenuItem value={0x49}>Gateway 2 (0x49)</MenuItem>
<MenuItem value={0x4a}>Gateway 3 (0x4A)</MenuItem>
<MenuItem value={0x4b}>Gateway 4 (0x4B)</MenuItem>
<MenuItem value={0x4c}>Gateway 5 (0x4C)</MenuItem>
<MenuItem value={0x4d}>Gateway 7 (0x4D)</MenuItem>
</TextField>
</Grid>
</Grid>
<BlockFormControlLabel
control={
<Checkbox
checked={data.readonly_mode}
onChange={updateFormValue}
name="readonly_mode"
/>
}
label={LL.READONLY()}
/>
{data.led_gpio !== 0 && (
<BlockFormControlLabel
control={
<Checkbox
checked={data.hide_led}
onChange={updateFormValue}
name="hide_led"
/>
}
label={LL.HIDE_LED()}
/>
)}
<BlockFormControlLabel
control={
<Checkbox
checked={data.low_clock}
onChange={updateFormValue}
name="low_clock"
/>
}
label={LL.UNDERCLOCK_CPU()}
/>
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
{LL.SPECIAL_FUNCTIONS()}
</Typography>
<BlockFormControlLabel
control={
<Checkbox
checked={data.developer_mode}
onChange={updateFormValue}
name="developer_mode"
/>
}
label={LL.DEVELOPER_MODE()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.boiler_heatingoff}
onChange={updateFormValue}
name="boiler_heatingoff"
/>
}
label={LL.HEATINGOFF()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.remote_timeout_en}
onChange={updateFormValue}
name="remote_timeout_en"
/>
}
label={LL.REMOTE_TIMEOUT_EN()}
/>
{data.remote_timeout_en && (
<Box mt={2}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="remote_timeout"
label={LL.REMOTE_TIMEOUT()}
slotProps={{
input: HoursInputProps
}}
variant="outlined"
value={numberValue(data.remote_timeout)}
type="number"
onChange={updateFormValue}
/>
</Box>
)}
<Grid container spacing={2} rowSpacing={0}>
<BlockFormControlLabel
control={
<Checkbox
checked={data.shower_timer}
onChange={updateFormValue}
name="shower_timer"
/>
}
label={LL.ENABLE_SHOWER_TIMER()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={data.shower_alert}
onChange={updateFormValue}
name="shower_alert"
/>
}
label={LL.ENABLE_SHOWER_ALERT()}
disabled={!data.shower_timer}
/>
</Grid>
<Grid container spacing={2} sx={{ pt: 2 }}>
{data.shower_timer && (
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="shower_min_duration"
label={LL.MIN_DURATION()}
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.shower_min_duration)}
fullWidth
type="number"
onChange={updateFormValue}
/>
</Grid>
)}
{data.shower_alert && (
<>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="shower_alert_trigger"
label={LL.TRIGGER_TIME()}
slotProps={{
input: MinutesInputProps
}}
variant="outlined"
value={numberValue(data.shower_alert_trigger)}
fullWidth
type="number"
onChange={updateFormValue}
disabled={!data.shower_timer}
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="shower_alert_coldshot"
label={LL.COLD_SHOT_DURATION()}
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.shower_alert_coldshot)}
fullWidth
type="number"
onChange={updateFormValue}
disabled={!data.shower_timer}
/>
</Grid>
</>
)}
</Grid>
{restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
onClick={restart}
>
{LL.RESTART()}
</Button>
</MessageBox>
)}
{!restartNeeded && dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
color="secondary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
</>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : content()}
</SectionContent>
);
};
export default ApplicationSettings;

View File

@@ -1,131 +0,0 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import { Box, Button, Typography } from '@mui/material';
import Grid from '@mui/material/Grid2';
import * as SystemApi from 'api/system';
import { API, callAction } from 'api/app';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import RestartMonitor from 'app/status/RestartMonitor';
import {
FormLoader,
SectionContent,
SingleUpload,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';
const DownloadUpload = () => {
const { LL } = useI18nContext();
const [restarting, setRestarting] = useState<boolean>(false);
const { send: sendExportData } = useRequest(
(type: string) => callAction({ action: 'export', param: type }),
{
immediate: false
}
)
.onSuccess((event) => {
saveFile(event.data, event.args[0], '.json');
toast.info(LL.DOWNLOAD_SUCCESSFUL());
})
.onError((error) => {
toast.error(error.message);
});
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)}
</Typography>
<Typography mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}.
</Typography>
<Grid container spacing={1}>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('settings')}
>
{LL.SETTINGS_OF(LL.APPLICATION())}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('customizations')}
>
{LL.CUSTOMIZATIONS()}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('entities')}
>
{LL.CUSTOM_ENTITIES(0)}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('schedule')}
>
{LL.SCHEDULE(0)}
</Button>
</Grid>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()}
</Typography>
<Box color="warning.main" sx={{ pb: 2 }}>
<Typography variant="body1">{LL.UPLOAD_TEXT()}.</Typography>
</Box>
<SingleUpload doRestart={doRestart} />
</>
);
};
return (
<SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
);
};
export default DownloadUpload;

View File

@@ -1,507 +0,0 @@
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import {
Button,
Checkbox,
InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { createMqttSettingsValidator, validate } from 'validators';
const MqttSettings = () => {
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<MqttSettingsType>({
read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings
});
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF('MQTT'));
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
return (
<>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()}
/>
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="host"
label={LL.ADDRESS_OF(LL.BROKER())}
multiline
variant="outlined"
value={data.host}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
label="Port"
variant="outlined"
value={numberValue(data.port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="base"
label={LL.BASE_TOPIC()}
variant="outlined"
value={data.base}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="username"
label={LL.USERNAME(0)}
variant="outlined"
value={data.username}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedPasswordField
name="password"
label={LL.PASSWORD()}
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="keep_alive"
label="Keep Alive"
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.keep_alive)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="mqtt_qos"
label="QoS"
value={data.mqtt_qos}
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()}
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}
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 spacing={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="publish_single"
checked={data.publish_single}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_1()}
/>
</Grid>
{data.publish_single && (
<Grid>
<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={2} rowSpacing={0}>
<Grid>
<BlockFormControlLabel
control={
<Checkbox
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<Grid container spacing={2} rowSpacing={0}>
<Grid>
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
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>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={3}>
{LL.MQTT_ENTITY_FORMAT_1()}&nbsp;(v3.6)
</MenuItem>
<MenuItem value={4}>
{LL.MQTT_ENTITY_FORMAT_2()}&nbsp;(v3.6)
</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={2} rowSpacing={0}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_heartbeat"
label="Heartbeat"
slotProps={{
input: SecondsInputProps
}}
variant="outlined"
value={numberValue(data.publish_time_heartbeat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid>
<TextField
name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()}
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_sensor"
label={LL.TEMP_SENSORS()}
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_other"
label={LL.DEFAULT(0)}
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
</Grid>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="secondary"
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;

View File

@@ -1,154 +0,0 @@
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem } from '@mui/material';
import * as NTPApi from 'api/ntp';
import { readNTPSettings } from 'api/ntp';
import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockFormControlLabel,
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
ValidatedTextField,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
const NTPSettings = () => {
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<NTPSettingsType>({
read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings
});
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF('NTP'));
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
updateFormValue(event);
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
}));
};
return (
<>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_NTP()}
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="server"
label={LL.NTP_SERVER()}
fullWidth
variant="outlined"
value={data.server}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="tz_label"
label={LL.TIME_ZONE()}
fullWidth
variant="outlined"
value={selectedTimeZone(data.tz_label, data.tz_format)}
onChange={changeTimeZone}
margin="normal"
select
>
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneSelectItems()}
</ValidatedTextField>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="secondary"
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 NTPSettings;

View File

@@ -1,176 +0,0 @@
import { useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock';
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 ViewModuleIcon from '@mui/icons-material/ViewModule';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List
} from '@mui/material';
import { API, callAction } from 'api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { useI18nContext } from 'i18n/i18n-react';
const Settings = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
// call checkUpgrade with no param to fetch EMS-ESP version
const { data } = useRequest(() => callAction({ action: 'checkUpgrade' }), {
initialData: { emsesp_version: '...' }
});
const doFormat = async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setConfirmFactoryReset(false);
});
};
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)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label={LL.EMS_ESP_VER()}
text={data.emsesp_version}
to="version"
/>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
label={LL.APPLICATION()}
text={LL.APPLICATION_SETTINGS_1()}
to="application"
/>
<ListMenuItem
icon={SettingsEthernetIcon}
bgcolor="#40828f"
label={LL.NETWORK(0)}
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
to="network"
/>
<ListMenuItem
icon={SettingsInputAntennaIcon}
bgcolor="#5f9a5f"
label={LL.ACCESS_POINT(0)}
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
to="ap"
/>
<ListMenuItem
icon={AccessTimeIcon}
bgcolor="#c5572c"
label="NTP"
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
to="ntp"
/>
<ListMenuItem
icon={DeviceHubIcon}
bgcolor="#68374d"
label="MQTT"
text={LL.CONFIGURE('MQTT')}
to="mqtt"
/>
<ListMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}
text={LL.SECURITY_1()}
to="security"
/>
<ListMenuItem
icon={ViewModuleIcon}
bgcolor="#efc34b"
label={LL.MODULES()}
text={LL.MODULES_1()}
to="modules"
/>
<ListMenuItem
icon={ImportExportIcon}
bgcolor="#5d89f7"
label={LL.DOWNLOAD_UPLOAD()}
text={LL.DOWNLOAD_UPLOAD_1()}
to="upload"
/>
</List>
{renderFactoryResetDialog()}
<Box mt={2} display="flex" flexWrap="wrap">
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</Box>
</>
);
return <SectionContent>{content()}</SectionContent>;
};
export default Settings;

View File

@@ -1,277 +0,0 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Link,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import * as SystemApi from 'api/system';
import { callAction } from 'api/app';
import { getDevVersion, getStableVersion } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import RestartMonitor from 'app/status/RestartMonitor';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
const Version = () => {
const { LL } = useI18nContext();
const [restarting, setRestarting] = useState<boolean>(false);
const [openDialog, setOpenDialog] = useState<boolean>(false);
const [useDev, setUseDev] = useState<boolean>(false);
const [upgradeAvailable, setUpgradeAvailable] = useState<boolean>(false);
const { send: sendCheckUpgrade } = useRequest(
(version: string) => callAction({ action: 'checkUpgrade', param: version }),
{
immediate: false
}
).onSuccess((event) => {
const data = event.data as { emsesp_version: string; upgradeable: boolean };
setUpgradeAvailable(data.upgradeable);
});
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{
immediate: false
}
);
// called immediately to get the latest version, on page load
const { data: latestVersion } = useRequest(getStableVersion, {
// uncomment next 2 lines for testing, uses https://github.com/emsesp/EMS-ESP32/releases/download/v3.6.5/EMS-ESP-3_6_5-ESP32-16MB+.bin
// immediate: false,
// initialData: '3.6.5'
});
// called immediately to get the latest version, on page load, then check for upgrade (works for both dev and stable)
const { data: latestDevVersion } = useRequest(getDevVersion, {
// uncomment next 2 lines for testing, uses https://github.com/emsesp/EMS-ESP32/releases/download/latest/EMS-ESP-3_7_0-dev_31-ESP32-16MB+.bin
// immediate: false,
// initialData: '3.7.0-dev.32'
}).onSuccess((event) => {
void sendCheckUpgrade(event.data);
});
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
const getBinURL = (useDevVersion: boolean) => {
if (!latestVersion || !latestDevVersion) {
return '';
}
const filename =
'EMS-ESP-' +
(useDevVersion ? latestDevVersion : latestVersion).replaceAll('.', '_') +
'-' +
getPlatform() +
'.bin';
return useDevVersion
? DEV_URL + filename
: STABLE_URL + 'v' + latestVersion + '/' + filename;
};
const getPlatform = () => {
return (
[data.esp_platform, data.flash_chip_size >= 16384 ? '16MB' : '4MB'].join('-') +
(data.psram ? '+' : '')
);
};
const installFirmwareURL = async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
};
useLayoutTitle(LL.EMS_ESP_VER());
const internet_live =
latestDevVersion !== undefined && latestVersion !== undefined;
const renderUploadDialog = () => {
if (!internet_live) {
return null;
}
return (
<Dialog
sx={dialogStyle}
open={openDialog}
onClose={() => setOpenDialog(false)}
>
<DialogTitle>
{LL.INSTALL('') +
' ' +
(useDev ? LL.DEVELOPMENT() : LL.STABLE()) +
' Firmware'}
</DialogTitle>
<DialogContent dividers>
<Typography mb={2}>
{LL.INSTALL_VERSION(useDev ? latestDevVersion : latestVersion)}
</Typography>
<Link
target="_blank"
href={useDev ? DEV_RELNOTES_URL : STABLE_RELNOTES_URL}
color="primary"
>
changelog
</Link>
&nbsp;|&nbsp;
<Link target="_blank" href={getBinURL(useDev)} color="primary">
{LL.DOWNLOAD(1)}
</Link>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setOpenDialog(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={() => installFirmwareURL(getBinURL(useDev))}
color="primary"
>
{LL.INSTALL('')}
</Button>
</DialogActions>
</Dialog>
);
};
// useDevVersion = true to force using the dev version
const showFirmwareDialog = (useDevVersion: boolean) => {
if (useDevVersion || data.emsesp_version.includes('dev')) {
setUseDev(true);
}
setOpenDialog(true);
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const isDev = data.emsesp_version.includes('dev');
return (
<>
<Box p={2} border="1px solid grey" borderRadius={2}>
<Grid container spacing={3}>
<Grid mb={1}>
<Typography mb={1} fontWeight={'fontWeightBold'}>
{LL.VERSION()}
</Typography>
<Typography mb={1} fontWeight={'fontWeightBold'}>
Platform
</Typography>
<Typography mb={1} fontWeight={'fontWeightBold'}>
Release
</Typography>
</Grid>
<Grid>
<Typography mb={1}>
{data.emsesp_version}
{data.build_flags && (
<Typography variant="caption">
&nbsp; &#40;{data.build_flags}&#41;
</Typography>
)}
</Typography>
<Typography mb={1}>{getPlatform()}</Typography>
<Typography>
{isDev ? LL.DEVELOPMENT() : LL.STABLE()}&nbsp;
<Link
target="_blank"
href={useDev ? DEV_RELNOTES_URL : STABLE_RELNOTES_URL}
color="primary"
>
(changelog)
</Link>
</Typography>
</Grid>
</Grid>
<Divider />
{!isDev && (
<Button
sx={{ mt: 2 }}
variant="outlined"
color="primary"
size="small"
onClick={() => showFirmwareDialog(true)}
>
{LL.SWITCH_DEV()}
</Button>
)}
<Typography mt={2} color="warning">
<InfoOutlinedIcon color="warning" sx={{ verticalAlign: 'middle' }} />
&nbsp;&nbsp;
{upgradeAvailable ? LL.UPGRADE_AVAILABLE() : LL.LATEST_VERSION()}
{upgradeAvailable &&
internet_live &&
(data.psram ? (
<Button
sx={{ ml: 2, textTransform: 'none' }}
variant="outlined"
color="primary"
size="small"
onClick={() => showFirmwareDialog(false)}
>
{isDev
? LL.INSTALL('v' + latestDevVersion)
: LL.INSTALL('v' + latestVersion)}
</Button>
) : (
<>
&nbsp;&nbsp;
<Link target="_blank" href={getBinURL(isDev)} color="primary">
{LL.DOWNLOAD(1)}&nbsp;v
{isDev ? latestDevVersion : latestVersion}
</Link>
</>
))}
</Typography>
{renderUploadDialog()}
</Box>
</>
);
};
return (
<SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
);
};
export default Version;

View File

@@ -1,57 +0,0 @@
import { useCallback, useState } from 'react';
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork } from 'types';
import NetworkSettings from './NetworkSettings';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
const Network = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF(LL.NETWORK(0)));
const { routerTab } = useRouterTab();
const navigate = useNavigate();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback(
(network: WiFiNetwork) => {
setSelectedNetwork(network);
navigate('settings');
},
[navigate]
);
const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined);
}, []);
return (
<WiFiConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}}
>
<RouterTabs value={routerTab}>
<Tab value="settings" label={LL.SETTINGS_OF(LL.NETWORK(1))} />
<Tab value="scan" label={LL.NETWORK_SCAN()} />
</RouterTabs>
<Routes>
<Route path="scan" element={<WiFiNetworkScanner />} />
<Route path="settings" element={<NetworkSettings />} />
<Route path="*" element={<Navigate replace to="settings" />} />
</Routes>
</WiFiConnectionContext.Provider>
);
};
export default Network;

View File

@@ -1,78 +0,0 @@
import { useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material';
import * as NetworkApi from 'api/network';
import { updateState, useRequest } from 'alova/client';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import WiFiNetworkSelector from './WiFiNetworkSelector';
const NUM_POLLS = 10;
const POLLING_FREQUENCY = 1000;
const WiFiNetworkScanner = () => {
const pollCount = useRef(0);
const { LL } = useI18nContext();
const [errorMessage, setErrorMessage] = useState<string>();
// is called on page load to start network scan
const { send: scanNetworks } = useRequest(NetworkApi.scanNetworks).onComplete(
() => {
pollCount.current = 0;
setErrorMessage(undefined);
void updateState(NetworkApi.listNetworks(), () => undefined);
void getNetworkList();
}
);
const { data: networkList, send: getNetworkList } = useRequest(
NetworkApi.listNetworks,
{
immediate: false
}
).onSuccess((event) => {
// is called when network scan is completed
if (!event.data) {
const completedPollCount = pollCount.current + 1;
if (completedPollCount < NUM_POLLS) {
pollCount.current = completedPollCount;
setTimeout(getNetworkList, POLLING_FREQUENCY);
} else {
setErrorMessage(LL.PROBLEM_LOADING());
pollCount.current = 0;
}
}
});
const renderNetworkScanner = () => {
if (!networkList) {
return (
<FormLoader message={LL.SCANNING() + '...'} errorMessage={errorMessage} />
);
}
return <WiFiNetworkSelector networkList={networkList} />;
};
return (
<SectionContent>
{renderNetworkScanner()}
<ButtonRow>
<Button
startIcon={<PermScanWifiIcon />}
variant="outlined"
color="secondary"
onClick={scanNetworks}
disabled={!errorMessage && !networkList}
>
{LL.SCAN_AGAIN()}&hellip;
</Button>
</ButtonRow>
</SectionContent>
);
};
export default WiFiNetworkScanner;

View File

@@ -1,100 +0,0 @@
import { useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Badge,
List,
ListItem,
ListItemAvatar,
ListItemIcon,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import { MessageBox } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork, WiFiNetworkList } from 'types';
import { WiFiEncryptionType } from 'types';
import { WiFiConnectionContext } from './WiFiConnectionContext';
export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
switch (encryption_type) {
case WiFiEncryptionType.WIFI_AUTH_WEP:
return 'WEP';
case WiFiEncryptionType.WIFI_AUTH_WPA_PSK:
return 'WPA';
case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK:
return 'WPA2';
case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
return 'WPA/WPA2';
case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
return 'WPA2 Enterprise';
case WiFiEncryptionType.WIFI_AUTH_OPEN:
return 'None';
case WiFiEncryptionType.WIFI_AUTH_WPA3_PSK:
return 'WPA3';
case WiFiEncryptionType.WIFI_AUTH_WPA2_WPA3_PSK:
return 'WPA2/WPA3';
default:
return 'Unknown: ' + String(encryption_type);
}
};
const networkQualityHighlight = ({ rssi }: WiFiNetwork, theme: Theme) => {
if (rssi <= -85) {
return theme.palette.error.main;
} else if (rssi <= -75) {
return theme.palette.warning.main;
}
return theme.palette.success.main;
};
const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList }) => {
const { LL } = useI18nContext();
const theme = useTheme();
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => (
<ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'dBm'}>
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
</Badge>
</ListItemIcon>
</ListItem>
);
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;
};
export default WiFiNetworkSelector;

View File

@@ -1,89 +0,0 @@
import { useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
LinearProgress,
TextField,
Typography
} from '@mui/material';
import * as SecurityApi from 'api/security';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { MessageBox } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
interface GenerateTokenProps {
username?: string;
onClose: () => void;
}
const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
const { LL } = useI18nContext();
const open = !!username;
const { data: token, send: generateToken } = useRequest(
SecurityApi.generateToken(username),
{
immediate: false
}
);
useEffect(() => {
if (open) {
void generateToken();
}
}, [open]);
return (
<Dialog
sx={dialogStyle}
onClose={onClose}
open={!!username}
fullWidth
maxWidth="sm"
>
<DialogTitle>{LL.ACCESS_TOKEN_FOR() + ' ' + username}</DialogTitle>
<DialogContent dividers>
{token ? (
<>
<MessageBox message={LL.ACCESS_TOKEN_TEXT()} level="info" my={2} />
<Box mt={2} mb={2}>
<TextField
label="Token"
multiline
value={token.token}
fullWidth
contentEditable={false}
/>
</Box>
</>
) : (
<Box m={4} textAlign="center">
<LinearProgress />
<Typography variant="h6">{LL.GENERATING_TOKEN()}&hellip;</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button
startIcon={<CloseIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default GenerateToken;

View File

@@ -1,32 +0,0 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import ManageUsers from './ManageUsers';
import SecuritySettings from './SecuritySettings';
const Security = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF(LL.SECURITY(0)));
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="settings" label={LL.SETTINGS_OF(LL.SECURITY(1))} />
<Tab value="users" label={LL.MANAGE_USERS()} />
</RouterTabs>
<Routes>
<Route path="users" element={<ManageUsers />} />
<Route path="settings" element={<SecuritySettings />} />
<Route path="*" element={<Navigate replace to="settings" />} />
</Routes>
</>
);
};
export default Security;

View File

@@ -1,118 +0,0 @@
import { useContext, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button } from '@mui/material';
import * as SecurityApi from 'api/security';
import type { ValidateFieldsError } from 'async-validator';
import {
BlockNavigation,
ButtonRow,
FormLoader,
MessageBox,
SectionContent,
ValidatedPasswordField
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
const SecuritySettings = () => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<SecuritySettingsType>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const authenticatedContext = useContext(AuthenticatedContext);
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(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
return (
<>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="jwt_secret"
label={LL.SU_PASSWORD()}
fullWidth
variant="outlined"
value={data.jwt_secret}
onChange={updateFormValue}
margin="normal"
/>
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="secondary"
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 SecuritySettings;

View File

@@ -1,112 +0,0 @@
import ComputerIcon from '@mui/icons-material/Computer';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as APApi from 'api/ap';
import { useAutoRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { APStatusType } from 'types';
import { APNetworkStatus } from 'types';
export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return theme.palette.success.main;
case APNetworkStatus.INACTIVE:
return theme.palette.info.main;
case APNetworkStatus.LINGERING:
return theme.palette.warning.main;
default:
return theme.palette.warning.main;
}
};
const APStatus = () => {
const {
data,
send: loadData,
error
} = useAutoRequest(APApi.readAPStatus, { pollingTime: 3000 });
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(LL.ACCESS_POINT(0)));
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0);
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: apStatusHighlight(data, theme) }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('MAC')}
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default APStatus;

View File

@@ -1,127 +0,0 @@
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme as tableTheme } from '@table-library/react-table-library/theme';
import { useAutoRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { Translation } from 'i18n/i18n-types';
import { readActivity } from '../../api/app';
import type { Stat } from '../main/types';
const SystemActivity = () => {
const {
data,
send: loadData,
error
} = useAutoRequest(readActivity, { pollingTime: 3000 });
const { LL } = useI18nContext();
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
height: 36px;
border-bottom: 1px solid #565656;
}
`,
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
&:nth-of-type(even) .td {
background-color: #1e1e1e;
}
`,
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
});
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] = id;
return LL.STATUS_NAMES[name]();
};
const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
}
if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
} else {
return <div style={{ color: 'red' }}>{stat.q}%</div>;
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<Table
data={{ nodes: data.stats }}
theme={stats_theme}
layout={{ custom: true }}
>
{(tableList: Stat[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize />
<HeaderCell stiff>{LL.SUCCESS()}</HeaderCell>
<HeaderCell stiff>{LL.FAIL()}</HeaderCell>
<HeaderCell stiff>{LL.QUALITY()}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((stat: Stat) => (
<Row key={stat.id} item={stat}>
<Cell>{showName(stat.id)}</Cell>
<Cell stiff>{Intl.NumberFormat().format(stat.s)}</Cell>
<Cell stiff>{Intl.NumberFormat().format(stat.f)}</Cell>
<Cell stiff>{showQuality(stat)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default SystemActivity;

View File

@@ -1,206 +0,0 @@
import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices';
import FolderIcon from '@mui/icons-material/Folder';
import MemoryIcon from '@mui/icons-material/Memory';
import SdCardAlertIcon from '@mui/icons-material/SdCardAlert';
import SdStorageIcon from '@mui/icons-material/SdStorage';
import TapAndPlayIcon from '@mui/icons-material/TapAndPlay';
import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@mui/material';
import * as SystemApi from 'api/system';
import { useAutoRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import BBQKeesIcon from './bbqkees.svg';
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const HardwareStatus = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(LL.HARDWARE()));
const {
data,
send: loadData,
error
} = useAutoRequest(SystemApi.readSystemStatus, { pollingTime: 3000 });
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<List>
<ListItem>
<ListItemAvatar>
{data.model ? (
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
</Avatar>
) : (
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<TapAndPlayIcon />
</Avatar>
)}
</ListItemAvatar>
<ListItemText
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
secondary={data.model ? data.model : data.cpu_type}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SDK"
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="CPU"
secondary={
data.esp_platform +
'/' +
data.cpu_type +
' (rev.' +
data.cpu_rev +
', ' +
(data.cpu_cores === 1 ? 'single-core)' : 'dual-core)') +
' @ ' +
data.cpu_freq_mhz +
' Mhz' +
(data.temperature ? ', T: ' + data.temperature + ' °C' : '')
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FREE_MEMORY()}
secondary={
formatNumber(data.free_heap) +
' KB (' +
formatNumber(data.max_alloc_heap) +
' KB max alloc, ' +
formatNumber(data.free_caps) +
' KB caps)'
}
/>
</ListItem>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.PSRAM()}
secondary={
formatNumber(data.psram_size) +
' KB / ' +
formatNumber(data.free_psram) +
' KB'
}
/>
</ListItem>
</>
)}
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FLASH()}
secondary={
formatNumber(data.flash_chip_size) +
' KB , ' +
(data.flash_chip_speed / 1000000).toFixed(0) +
' MHz'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdCardAlertIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.APPSIZE()}
secondary={
data.partition +
': ' +
formatNumber(data.app_used) +
' KB / ' +
formatNumber(data.app_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FILESYSTEM()}
secondary={
formatNumber(data.fs_used) +
' KB / ' +
formatNumber(data.fs_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default HardwareStatus;

View File

@@ -1,169 +0,0 @@
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ReportIcon from '@mui/icons-material/Report';
import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff';
import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as MqttApi from 'api/mqtt';
import { useAutoRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types';
export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType,
theme: Theme
) => {
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
};
export const mqttPublishHighlight = (
{ mqtt_fails }: MqttStatusType,
theme: Theme
) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
};
export const mqttQueueHighlight = (
{ mqtt_queued }: MqttStatusType,
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main;
return theme.palette.warning.main;
};
const MqttStatus = () => {
const {
data,
send: loadData,
error
} = useAutoRequest(MqttApi.readMqttStatus, { pollingTime: 3000 });
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF('MQTT'));
const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + (connect_count > 1 ? ' (' + connect_count + ')' : '');
}
return LL.DISCONNECTED() + (connect_count > 1 ? ' (' + connect_count + ')' : '');
};
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'TLS fingerprint invalid';
default:
return 'Unknown';
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const renderConnectionStatus = () => (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={disconnectReason(data)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
return (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttStatusHighlight(data, theme) }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default MqttStatus;

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