Merge remote-tracking branch 'origin/dev'

This commit is contained in:
proddy
2024-10-27 11:41:47 +01:00
627 changed files with 63678 additions and 48725 deletions

View File

@@ -1,32 +1,32 @@
--- ---
name: Problem Report name: Problem Report/Change Request
about: Create a Report to help us improve about: Create a Report to help us improve
--- ---
<!-- Thanks for reporting a problem for this project. READ THIS FIRST: <!-- Thanks for reporting an issue 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 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 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 take a few minutes to complete the requested information below. Please take a few minutes to complete the requested information below.
--> -->
### PROBLEM DESCRIPTION ### DESCRIPTION
_A clear and concise description of what the problem is._ _A clear and concise description of what the problem is or the change requested._
### 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 problem in [issues](https://github.com/emsesp/EMS-ESP32/issues) - [ ] Searched the issue in [issues](https://github.com/emsesp/EMS-ESP32/issues)
- [ ] Searched the problem in [discussions](https://github.com/emsesp/EMS-ESP32/discussions) - [ ] Searched the issue in [discussions](https://github.com/emsesp/EMS-ESP32/discussions)
- [ ] Searched the problem in the [docs](https://emsesp.github.io/docs/Troubleshooting/) - [ ] Searched the issue in the [docs](https://emsesp.org/Troubleshooting/)
- [ ] Searched the problem in the [chat](https://discord.gg/3J3GgnzpyT) - [ ] Searched the issue in the [chat](https://discord.gg/3J3GgnzpyT)
- [ ] Provide the output of http://ems-esp.local/api/system : - [ ] Provide the System information in the area below, taken from `http://<IP>/api/system`
```lua ```json
System information output here: Paste System information 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 problem._ _If applicable, add screenshots to help explain your issue._
### ADDITIONAL CONTEXT ### ADDITIONAL CONTEXT
_Add any other context about the problem here._ _Add any other context about the issue here._
**(Please, remember to close the issue when the problem has been addressed)** **(Please remember to close the issue when it 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.github.io/docs/ url: https://emsesp.org
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

@@ -11,7 +11,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Github Releases To Discord
- name: GitHub Releases To Discord
uses: SethCohen/github-releases-to-discord@v1.13.1 uses: SethCohen/github-releases-to-discord@v1.13.1
with: with:
webhook_url: ${{ secrets.WEBHOOK_URL }} webhook_url: ${{ secrets.WEBHOOK_URL }}

View File

@@ -10,17 +10,24 @@ jobs:
pre-release: pre-release:
name: 'Automatic pre-release build' name: 'Automatic pre-release build'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
- uses: actions/setup-python@v4 uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Install python 3.11
uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Get EMS-ESP source code and version - name: Install Node.js 20
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}'`
@@ -40,21 +47,13 @@ jobs:
yarn build yarn build
yarn webUI yarn webUI
- name: Build firmware - name: Build all PIO target environments from default_envs
run: | run: |
platformio run -e ci platformio run
- name: Build S3 firmware - name: Create GitHub Release
run: |
platformio run -e ci_s3
- name: Build E32V2 firmware
run: |
platformio run -e ci_16M
- name: Create a GH Release
id: 'automatic_releases' id: 'automatic_releases'
uses: 'marvinpinto/action-automatic-releases@latest' uses: emsesp/action-automatic-releases@v1.0.0
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}}

View File

@@ -1,30 +1,34 @@
# 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 and analyze
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 BUILD_WRAPPER_OUT_DIR: bw-output
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0
- name: Install sonar-scanner and build-wrapper - name: Install sonar-scanner and build-wrapper
uses: SonarSource/sonarcloud-github-c-cpp@v2 uses: SonarSource/sonarcloud-github-c-cpp@v2
- name: Run build-wrapper - name: Run build-wrapper
run: | run: build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make all
- name: Run sonar-scanner - name: Run sonar-scanner
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: | run: sonar-scanner --define sonar.cfamily.compile-commands="${{ env.BUILD_WRAPPER_OUT_DIR }}/compile_commands.json"
sonar-scanner --define sonar.cfamily.build-wrapper-output="${{ env.BUILD_WRAPPER_OUT_DIR }}"

View File

@@ -1,6 +1,7 @@
name: 'tagged-release' name: 'tagged-release'
on: on:
workflow_dispatch:
push: push:
tags: tags:
- 'v*' - 'v*'
@@ -9,22 +10,27 @@ jobs:
tagged-release: tagged-release:
name: 'Tagged Release' name: 'Tagged Release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
- uses: actions/setup-python@v4 uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Install python 3.11
uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- uses: actions/setup-node@v3
- name: Install Node.js 20
uses: actions/setup-node@v4
with: with:
node-version: '18' 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: |
@@ -35,16 +41,12 @@ jobs:
yarn build yarn build
yarn webUI yarn webUI
- name: Build firmware - name: Build all PIO target environments from default_envs
run: | run: |
platformio run -e ci platformio run
- name: Build S3 firmware - name: Create GitHub Release
run: | uses: emsesp/action-automatic-releases@v1.0.0
platformio run -e ci_s3
- name: Release
uses: 'marvinpinto/action-automatic-releases@latest'
with: with:
repo_token: '${{ secrets.GITHUB_TOKEN }}' repo_token: '${{ secrets.GITHUB_TOKEN }}'
prerelease: false prerelease: false

View File

@@ -10,27 +10,26 @@ jobs:
pre-release: pre-release:
name: 'Automatic test-release build' name: 'Automatic test-release build'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v4 - name: Enable Corepack
run: corepack enable
- uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- uses: actions/setup-node@v3 - name: Use Node.js 20.x
uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20.x'
- name: Get EMS-ESP source code and version - name: Get EMS-ESP source code and version
id: build_info id: build_info
run: | run: |
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'` version=`grep -E '^#define EMSESP_APP_VERSION' ./src/version.h | awk -F'"' '{print $2}'`
echo "VERSION=$version" >> $GITHUB_OUTPUT echo "VERSION=$version" >> $GITHUB_OUTPUT
- 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
- name: Build WebUI - name: Build WebUI
run: | run: |
cd interface cd interface
@@ -39,18 +38,12 @@ jobs:
sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts sed -i "s/= 'pl'/= 'en'/" ./src/i18n/i18n-util.ts
yarn build yarn build
yarn webUI yarn webUI
- name: Build all target environments from default_envs
- name: Build firmware
run: | run: |
platformio run -e ci platformio run
- name: Create GitHub Release
- name: Build S3 firmware
run: |
platformio run -e ci_s3
- name: Create a GH Release
id: 'automatic_releases' id: 'automatic_releases'
uses: 'marvinpinto/action-automatic-releases@latest' uses: emsesp/action-automatic-releases@v1.0.0
with: with:
repo_token: '${{ secrets.GITHUB_TOKEN }}' repo_token: '${{ secrets.GITHUB_TOKEN }}'
title: Test Build v${{steps.build_info.outputs.VERSION}} title: Test Build v${{steps.build_info.outputs.VERSION}}

34
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# vscode # vscode
.vscode/* .vscode/c_cpp_properties.json
.vscode/extensions.json
.vscode/launch.json
.vscode/settings.json
# c++ compiling # c++ compiling
.clang_complete .clang_complete
@@ -27,21 +30,16 @@ stats.html
*.sln *.sln
*.sw? *.sw?
.pnp.* .pnp.*
.yarn/* */.yarn/*
!.yarn/patches !.yarn/patches
!.yarn/plugins !.yarn/plugins
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
yarn.lock yarn.lock
interface/analyse.html analyse.html
interface/vite.config.ts.timestamp* interface/vite.config.ts.timestamp*
*.local
# scripts
test.sh
scripts/run.sh
scripts/__pycache__
scripts/stackdmp.txt
# i18n generated files # i18n generated files
interface/src/i18n/i18n-react.tsx interface/src/i18n/i18n-react.tsx
@@ -50,11 +48,27 @@ 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/ bw-output/
# testing # standalone executable for testing
emsesp emsesp
interface/tsconfig.tsbuildinfo
# python virtual environment
venv/
# cspell
words-found-verbose.txt
# sonarlint
compile_commands.json
package.json

6
.prettierignore Normal file
View File

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

View File

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

View File

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

View File

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

101
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,101 @@
{
"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,6 +5,93 @@ 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 ## [3.6.5] March 23 2024
## **IMPORTANT! BREAKING CHANGES** ## **IMPORTANT! BREAKING CHANGES**

View File

@@ -1,11 +1 @@
# Changelog # Changelog
## [3.x]
## **IMPORTANT! BREAKING CHANGES**
## Added
## Fixed
## Changed

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.github.io/docs) - contributing to missing [documentation](https://emsesp.org)
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,9 +1,11 @@
# #
# GNUMakefile for EMS-ESP # GNUMakefile for EMS-ESP
# This is mainly used to generate the .o files for SonarQube analysis
# #
NUMJOBS=${NUMJOBS:-" -j4 "} # NUMJOBS=${NUMJOBS:-" -j10 "}
MAKEFLAGS+="j " # MAKEFLAGS+="j "
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Project Structure # Project Structure
#---------------------------------------------------------------------- #----------------------------------------------------------------------
@@ -29,8 +31,7 @@ CHECKFLAGS = -q --force --std=c++11
# Languages Standard # Languages Standard
#---------------------------------------------------------------------- #----------------------------------------------------------------------
C_STANDARD := -std=c17 C_STANDARD := -std=c17
# CXX_STANDARD := -std=c++17 CXX_STANDARD := -std=gnu++14
CXX_STANDARD := -std=gnu++11
# C_STANDARD := -std=c11 # C_STANDARD := -std=c11
# CXX_STANDARD := -std=c++11 # CXX_STANDARD := -std=c++11
@@ -38,11 +39,11 @@ CXX_STANDARD := -std=gnu++11
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Defined Symbols # Defined Symbols
#---------------------------------------------------------------------- #----------------------------------------------------------------------
DEFINES += -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
DEFINES += -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_TEST -D__linux__ -DEMC_RX_BUFFER_SIZE=1500 DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
DEFINES += $(ARGS) DEFINES += $(ARGS)
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.6.5-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\" DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_TX_MODE=8 -DEMSESP_DEFAULT_VERSION=\"3.7.0-dev\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32\"
#---------------------------------------------------------------------- #----------------------------------------------------------------------
# Sources & Files # Sources & Files
@@ -81,8 +82,8 @@ CPPFLAGS += -g3
CPPFLAGS += -Os CPPFLAGS += -Os
CFLAGS += $(CPPFLAGS) CFLAGS += $(CPPFLAGS)
CFLAGS += -Wall -Wextra -Werror -Wswitch-enum -Wno-unused-parameter -Wno-inconsistent-missing-override -Wno-missing-braces -Wno-unused-lambda-capture -Wno-sign-compare CFLAGS += -Wall -Wextra -Werror -Wswitch-enum
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
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://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&utm_medium=referral&utm_content=emsesp/EMS-ESP32&utm_campaign=Badge_Grade) [![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)
[![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,41 +12,60 @@
[![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network) [![GitHub forks](https://img.shields.io/github/forks/emsesp/EMS-ESP32.svg?style=social&label=Fork)](https://github.com/emsesp/EMS-ES32P/network)
[![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2) [![donate](https://img.shields.io/badge/donate-PayPal-blue.svg)](https://www.paypal.com/paypalme/prderbyshire/2)
**EMS-ESP** is an open-source firmware for the Espressif ESP32 microcontroller that communicates with **EMS** (Energy Management System) based equipment from manufacturers like Bosch, Buderus, Nefit, Junkers, Worcester and Sieger. It requires a small gateway circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built. **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.
## **Features** It requires a small circuit to interface with the EMS bus which can be purchased from <https://bbqkees-electronics.nl> or custom built.
- A multi-user, multi-language secure web interface to change settings and monitor incoming data ## **Key Features**
- A console, accessible via Serial and Telnet for more advanced monitoring
- Native support for Home Assistant, Domoticz and openHAB via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/) - Compatible with EMS, EMS+, EMS2, EMS Plus, Logamatic EMS, Junkers 2-wire, Heatronic 3 and 4
- Can run standalone as an independent WiFi Access Point or join an existing WiFi network - Supporting over 120 different EMS compatible devices such as thermostats, boilers, heat pumps, mixing units, solar modules, connect modules, ventilation units, switches and more
- Easy first-time configuration via a web Captive Portal - Easy to add external Temperature and Analog sensors that are attached to GPIO pins on the ESP32 board
- Support for more than [110+ EMS devices](https://emsesp.github.io/docs/All-Devices/) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways, switches, heat sources) - A multi-user, multi-language web interface to change settings and monitor incoming data
- 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**
For the complete documentation on how to install, configure and get support visit the [EMS-ESP Wiki](https://emsesp.org). 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.
## **Support** ## **Getting 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 like **EMS-ESP**, please give it a star, or fork it and contribute or offer a small donation! 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.
## **Demo** ## **Live Demo**
For a live demo of the Web UI click [here](https://demo.emsesp.org) and log in with any username/password. 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.
## **Contributors** ## **Contributors**
EMS-ESP is a project owned and maintained by [proddy](https://github.com/proddy) and [MichaelDvP](https://github.com/MichaelDvP). 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).
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 framework that provides the core of the Web UI - [esp8266-react](https://github.com/rjwats/esp8266-react) by @rjwats for the core framework that provides the Web UI, which has been heavily modified
- [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these open source libraries - [uuid-\*](https://github.com/nomis/mcu-uuid-console) from @nomis. The console, syslog, telnet and logging are based off these awesome open source libraries
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) for all the JSON processing
- [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client, with custom modifications from @MichaelDvP and @proddy - [espMqttClient](https://github.com/bertmelis/espMqttClient) for the MQTT client
- 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**
@@ -59,14 +78,14 @@ This program is licensed under GPL-3.0
| | | | | |
| ---------------------------------- | -------------------------------- | | ---------------------------------- | -------------------------------- |
| <img src="media/web_settings.png"> | <img src="media/web_status.png"> | | ![Web Settings](media/web_settings.png) | ![Web Status](media/web_status.png) |
| <img src="media/web_devices.png"> | <img src="media/web_mqtt.png"> | | ![Web Devices](media/web_devices.png) | ![Web MQTT](media/web_mqtt.png) |
| <img src="media/web_edit.png"> | <img src="media/web_log.png"> | | ![Web Edit](media/web_edit.png) | ![Web Log](media/web_log.png) |
### Telnet Console ### Telnet Console
<img src="media/console0.png" width=80% height=80%> ![Console](media/console0.png)
### In Home Assistant ### Home Assistant
<img src="media/ha_lovelace.png" width=80% height=80%> ![Home Assistant](media/ha_lovelace.png)

13
cspell.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$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

212
dump_telegrams.csv Normal file
View File

@@ -0,0 +1,212 @@
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,8 +1,9 @@
# Name, Type, SubType, Offset, Size, Flags # Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x005000, nvs, data, nvs, 0x9000, 0x005000,
otadata, data, ota, , 0x002000, otadata, data, ota, , 0x002000,
app0, app, ota_0, , 0x5D0000, boot, app, factory, , 0x480000,
app1, app, ota_1, , 0x5D0000, app0, app, ota_0, , 0x490000,
app1, app, ota_1, , 0x490000,
nvs1, data, nvs, , 0x040000, nvs1, data, nvs, , 0x040000,
spiffs, data, spiffs, , 0x400000, spiffs, data, spiffs, , 0x200000,
coredump, data, coredump,, 0x010000, coredump, data, coredump,, 0x010000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x005000
3 otadata data ota 0x002000
4 app0 boot app ota_0 factory 0x5D0000 0x480000
5 app1 app0 app ota_1 ota_0 0x5D0000 0x490000
6 app1 app ota_1 0x490000
7 nvs1 data nvs 0x040000
8 spiffs data spiffs 0x400000 0x200000
9 coredump data coredump 0x010000

View File

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

View File

@@ -25,11 +25,6 @@ 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=\"\"

View File

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

View File

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

4
interface/.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,44 @@
// @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,78 +1,67 @@
{ {
"name": "EMS-ESP", "name": "EMS-ESP",
"version": "3.6.5", "version": "3.7.0",
"description": "build EMS-ESP WebUI", "description": "EMS-ESP WebUI",
"homepage": "https://emsesp.github.io/docs", "homepage": "https://emsesp.org",
"author": "proddy", "author": "proddy, emsesp.org",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted", "build-hosted": "typesafe-i18n --no-watch && vite build --mode hosted",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"npm:mock-api\" \"vite preview\"", "preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"npm:mock-rest\" \"vite preview\"",
"mock-api": "bun --watch ../mock-api/server.ts", "mock-rest": "bun --watch ../mock-api/rest_server.ts",
"old_mock-api": "bun --watch ../mock-api/server.js", "standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-rest\" \"vite\"",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:mock-api\" \"vite\"",
"old_standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"npm:old_mock-api\" \"vite\"",
"typesafe-i18n": "typesafe-i18n --no-watch", "typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js", "webUI": "node progmem-generator.js",
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'", "format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --cache --fix" "lint": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@alova/adapter-xhr": "^1.0.3", "@alova/adapter-xhr": "2.0.9",
"@babel/core": "^7.24.3", "@emotion/react": "^11.13.3",
"@emotion/react": "^11.11.4", "@emotion/styled": "^11.13.0",
"@emotion/styled": "^11.11.0", "@mui/icons-material": "^6.1.5",
"@mui/icons-material": "^5.15.14", "@mui/material": "^6.1.5",
"@mui/material": "^5.15.14",
"@table-library/react-table-library": "4.1.7", "@table-library/react-table-library": "4.1.7",
"@types/imagemin": "^8.0.5", "alova": "3.1.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.30",
"@types/react": "^18.2.69",
"@types/react-dom": "^18.2.22",
"@types/react-router-dom": "^5.3.3",
"alova": "^2.18.0",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"history": "^5.3.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"react": "latest", "preact": "^10.24.3",
"react-dom": "latest", "react": "^18.3.1",
"react-dropzone": "^14.2.3", "react-dom": "^18.3.1",
"react-icons": "^5.0.1", "react-icons": "^5.3.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.27.0",
"react-toastify": "^10.0.5", "react-toastify": "^10.0.6",
"sockette": "^2.0.6",
"typesafe-i18n": "^5.26.2", "typesafe-i18n": "^5.26.2",
"typescript": "^5.4.3" "typescript": "^5.6.3"
}, },
"devDependencies": { "devDependencies": {
"@preact/compat": "^17.1.2", "@babel/core": "^7.26.0",
"@preact/preset-vite": "^2.8.2", "@eslint/js": "^9.13.0",
"@typescript-eslint/eslint-plugin": "^7.3.1", "@preact/compat": "^18.3.1",
"@typescript-eslint/parser": "^7.3.1", "@preact/preset-vite": "^2.9.1",
"concurrently": "^8.2.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"eslint": "^8.57.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", "eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1", "formidable": "^3.5.2",
"eslint-plugin-autofix": "^1.1.0", "prettier": "^3.3.3",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "alpha",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"preact": "^10.20.1",
"prettier": "^3.2.5",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"terser": "^5.29.2", "terser": "^5.36.0",
"vite": "^5.2.4", "typescript-eslint": "8.11.0",
"vite": "^5.4.10",
"vite-plugin-imagemin": "^0.6.1", "vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^5.0.1"
}, },
"packageManager": "yarn@4.1.1" "packageManager": "yarn@4.5.1"
} }

View File

@@ -1,8 +1,14 @@
import { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } from 'fs';
import { resolve, relative, sep } from 'path';
import zlib from 'zlib';
import mime from 'mime-types';
import crypto from 'crypto'; import crypto from 'crypto';
import {
createWriteStream,
existsSync,
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 INDENT = ' ';
@@ -18,12 +24,7 @@ const generateWWWClass = () =>
class WWWData { class WWWData {
${indent}public: ${indent}public:
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { ${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo ${fileInfo.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`).join('\n')}
.map(
(file) =>
`${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size}, "${file.hash}");`
)
.join('\n')}
${indent.repeat(2)}} ${indent.repeat(2)}}
}; };
`; `;

View File

@@ -1,5 +1,6 @@
/* /*
* Uses font-size 400 (normal) only and Latin (plus extra unicode chars) to keep flash memory to a minimum * Uses font-weight 400 (normal) only, no bold, and Latin with a few extra unicode chars.
* This is to keep flash memory to a minimum
* View fonts on https://fonts.google.com/ * View fonts on https://fonts.google.com/
* Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto * Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto
*/ */
@@ -12,7 +13,8 @@
local('Roboto'), local('Roboto'),
local('Roboto-Regular'), local('Roboto-Regular'),
url(../fonts/re.woff2) format('woff2'); 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, unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131,
U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+0141-0144, U+0152-0153, U+015A-015B, U+015E-015F, U+0179-017C, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD; U+2212, U+2215, U+FEFF, U+FFFD;
} }

View File

@@ -1,20 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ToastContainer, Slide } from 'react-toastify'; import { Slide, ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.min.css'; import 'react-toastify/dist/ReactToastify.min.css';
import { localStorageDetector } from 'typesafe-i18n/detectors';
import type { FC } from 'react';
import AppRouting from 'AppRouting'; import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme'; import CustomTheme from 'CustomTheme';
import TypesafeI18n from 'i18n/i18n-react'; import TypesafeI18n from 'i18n/i18n-react';
import { detectLocale } from 'i18n/i18n-util'; import { detectLocale } from 'i18n/i18n-util';
import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { localStorageDetector } from 'typesafe-i18n/detectors';
const detectedLocale = detectLocale(localStorageDetector); const detectedLocale = detectLocale(localStorageDetector);
const App: FC = () => { const App = () => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
useEffect(() => { useEffect(() => {

View File

@@ -1,14 +1,10 @@
import { useContext, useEffect } from 'react'; import { useContext, useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Route, Routes, Navigate, useLocation } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import type { FC } from 'react';
import AuthenticatedRouting from 'AuthenticatedRouting'; import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn'; import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components'; import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication'; import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
@@ -17,7 +13,7 @@ interface SecurityRedirectProps {
signOut?: boolean; signOut?: boolean;
} }
const RootRedirect: FC<SecurityRedirectProps> = ({ message, signOut }) => { const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
useEffect(() => { useEffect(() => {
signOut && authenticationContext.signOut(false); signOut && authenticationContext.signOut(false);
@@ -26,29 +22,20 @@ const RootRedirect: FC<SecurityRedirectProps> = ({ message, signOut }) => {
return <Navigate to="/" />; return <Navigate to="/" />;
}; };
export const RemoveTrailingSlashes = () => { const AppRouting = () => {
const location = useLocation();
return (
location.pathname.match('/.*/$') && (
<Navigate
to={{
pathname: location.pathname.replace(/\/+$/, ''),
search: location.search
}}
/>
)
);
};
const AppRouting: FC = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
return ( return (
<Authentication> <Authentication>
<RemoveTrailingSlashes />
<Routes> <Routes>
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} /> <Route
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />} /> path="/unauthorized"
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
/>
<Route
path="/fileUpdated"
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
/>
<Route <Route
path="/" path="/"
element={ element={

View File

@@ -1,64 +1,76 @@
import { Navigate, Routes, Route } from 'react-router-dom'; import { useContext } from 'react';
import Dashboard from './project/Dashboard'; import { Navigate, Route, Routes } from 'react-router-dom';
import Help from './project/Help';
import Settings from './project/Settings';
import type { FC } from 'react';
import { Layout, RequireAdmin } from 'components'; import CustomEntities from 'app/main/CustomEntities';
import AccessPoint from 'framework/ap/AccessPoint'; import Customizations from 'app/main/Customizations';
import Mqtt from 'framework/mqtt/Mqtt'; import Dashboard from 'app/main/Dashboard';
import NetworkConnection from 'framework/network/NetworkConnection'; import Devices from 'app/main/Devices';
import NetworkTime from 'framework/ntp/NetworkTime'; import Help from 'app/main/Help';
import Security from 'framework/security/Security'; import Modules from 'app/main/Modules';
import System from 'framework/system/System'; import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
const AuthenticatedRouting: FC = () => ( import APSettings from 'app/settings/APSettings';
// const location = useLocation(); import ApplicationSettings from 'app/settings/ApplicationSettings';
// const navigate = useNavigate(); import DownloadUpload from 'app/settings/DownloadUpload';
// const handleApiResponseError = useCallback( import MqttSettings from 'app/settings/MqttSettings';
// (error: AxiosError) => { import NTPSettings from 'app/settings/NTPSettings';
// if (error.response && error.response.status === 401) { import Settings from 'app/settings/Settings';
// AuthenticationApi.storeLoginRedirect(location); import Version from 'app/settings/Version';
// navigate('/unauthorized'); import Network from 'app/settings/network/Network';
// } import Security from 'app/settings/security/Security';
// return Promise.reject(error); import APStatus from 'app/status/APStatus';
// }, import Activity from 'app/status/Activity';
// [location, navigate] import HardwareStatus from 'app/status/HardwareStatus';
// ); import MqttStatus from 'app/status/MqttStatus';
// useEffect(() => { import NTPStatus from 'app/status/NTPStatus';
// const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError); import NetworkStatus from 'app/status/NetworkStatus';
// return () => AXIOS.interceptors.response.eject(axiosHandlerId); import Status from 'app/status/Status';
// }, [handleApiResponseError]); import SystemLog from 'app/status/SystemLog';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = () => {
const { me } = useContext(AuthenticatedContext);
return (
<Layout> <Layout>
<Routes> <Routes>
<Route path="/dashboard/*" element={<Dashboard />} /> <Route path="/dashboard/*" element={<Dashboard />} />
<Route <Route path="/devices/*" element={<Devices />} />
path="/settings/*" <Route path="/sensors/*" element={<Sensors />} />
element={ <Route path="/status/*" element={<Status />} />
<RequireAdmin>
<Settings />
</RequireAdmin>
}
/>
<Route path="/help/*" element={<Help />} /> <Route path="/help/*" element={<Help />} />
<Route path="/network/*" element={<NetworkConnection />} />
<Route path="/ap/*" element={<AccessPoint />} />
<Route path="/ntp/*" element={<NetworkTime />} />
<Route path="/mqtt/*" element={<Mqtt />} />
<Route
path="/security/*"
element={
<RequireAdmin>
<Security />
</RequireAdmin>
}
/>
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to="/" />} /> <Route path="/*" element={<Navigate to="/" />} />
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
<Route path="/status/activity" element={<Activity />} />
<Route path="/status/log" element={<SystemLog />} />
<Route path="/status/mqtt" element={<MqttStatus />} />
<Route path="/status/ntp" element={<NTPStatus />} />
<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 />} />
</>
)}
</Routes> </Routes>
</Layout> </Layout>
); );
};
export default AuthenticatedRouting; export default AuthenticatedRouting;

View File

@@ -1,7 +1,12 @@
import { CssBaseline } from '@mui/material';
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
import type { FC } from 'react'; import type { FC } from 'react';
import { CssBaseline } from '@mui/material';
import {
ThemeProvider,
createTheme,
responsiveFontSizes
} from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
export const dialogStyle = { export const dialogStyle = {
@@ -10,8 +15,7 @@ export const dialogStyle = {
borderColor: '#565656', borderColor: '#565656',
borderStyle: 'solid', borderStyle: 'solid',
borderWidth: '1px' borderWidth: '1px'
}, }
backdropFilter: 'blur(1px)'
}; };
const theme = responsiveFontSizes( const theme = responsiveFontSizes(

View File

@@ -1,38 +1,28 @@
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Paper, Typography, MenuItem, TextField, Button } from '@mui/material';
import { useRequest } from 'alova';
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material';
import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator'; import type { ValidateFieldsError } from 'async-validator';
import {
import type { Locales } from 'i18n/i18n-types'; LanguageSelector,
import type { ChangeEventHandler, FC } from 'react'; ValidatedPasswordField,
import type { SignInRequest } from 'types'; ValidatedTextField
import * as AuthenticationApi from 'api/authentication'; } from 'components';
import { PROJECT_NAME } from 'api/env';
import { ValidatedPasswordField, ValidatedTextField } from 'components';
import { AuthenticationContext } from 'contexts/authentication'; import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import DEflag from 'i18n/DE.svg'; import { useI18nContext } from 'i18n/i18n-react';
import FRflag from 'i18n/FR.svg'; import type { SignInRequest } from 'types';
import GBflag from 'i18n/GB.svg';
import ITflag from 'i18n/IT.svg';
import NLflag from 'i18n/NL.svg';
import NOflag from 'i18n/NO.svg';
import PLflag from 'i18n/PL.svg';
import SKflag from 'i18n/SK.svg';
import SVflag from 'i18n/SV.svg';
import TRflag from 'i18n/TR.svg';
import { I18nContext } from 'i18n/i18n-react';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { onEnterCallback, updateValue } from 'utils'; import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators'; import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn: FC = () => { const SignIn = () => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
const { LL, setLocale, locale } = useContext(I18nContext); const { LL } = useI18nContext();
const [signInRequest, setSignInRequest] = useState<SignInRequest>({ const [signInRequest, setSignInRequest] = useState<SignInRequest>({
username: '', username: '',
@@ -41,11 +31,12 @@ const SignIn: FC = () => {
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: callSignIn, onSuccess } = useRequest((request: SignInRequest) => AuthenticationApi.signIn(request), { const { send: callSignIn } = useRequest(
(request: SignInRequest) => AuthenticationApi.signIn(request),
{
immediate: false immediate: false
}); }
).onSuccess((response) => {
onSuccess((response) => {
if (response.data) { if (response.data) {
authenticationContext.signIn(response.data.access_token); authenticationContext.signIn(response.data.access_token);
} }
@@ -54,7 +45,7 @@ const SignIn: FC = () => {
const updateLoginRequestValue = updateValue(setSignInRequest); const updateLoginRequestValue = updateValue(setSignInRequest);
const signIn = async () => { const signIn = async () => {
await callSignIn(signInRequest).catch((event) => { await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') { if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN()); toast.warning(LL.INVALID_LOGIN());
} else { } else {
@@ -72,21 +63,14 @@ const SignIn: FC = () => {
try { try {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest); await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
await signIn(); await signIn();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
setProcessing(false); setProcessing(false);
} }
}; };
const submitOnEnter = onEnterCallback(signIn); const submitOnEnter = onEnterCallback(signIn);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
};
return ( return (
<Box <Box
display="flex" display="flex"
@@ -110,48 +94,7 @@ const SignIn: FC = () => {
> >
<Typography variant="h4">{PROJECT_NAME}</Typography> <Typography variant="h4">{PROJECT_NAME}</Typography>
<TextField name="locale" variant="outlined" value={locale} onChange={onLocaleSelected} size="small" select> <LanguageSelector />
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField>
<Box display="flex" flexDirection="column" alignItems="center"> <Box display="flex" flexDirection="column" alignItems="center">
<ValidatedTextField <ValidatedTextField
@@ -166,6 +109,12 @@ const SignIn: FC = () => {
onChange={updateLoginRequestValue} onChange={updateLoginRequestValue}
margin="normal" margin="normal"
variant="outlined" variant="outlined"
slotProps={{
input: {
autoCapitalize: 'none',
autoCorrect: 'off'
}
}}
/> />
<ValidatedPasswordField <ValidatedPasswordField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
@@ -182,7 +131,13 @@ const SignIn: FC = () => {
/> />
</Box> </Box>
<Button variant="contained" 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> </Button>

View File

@@ -1,7 +1,9 @@
import type { APSettingsType, APStatusType } from 'types';
import { alovaInstance } from './endpoints'; import { alovaInstance } from './endpoints';
import type { APSettings, APStatus } from 'types'; export const readAPStatus = () => alovaInstance.Get<APStatusType>('/rest/apStatus');
export const readAPSettings = () =>
export const readAPStatus = () => alovaInstance.Get<APStatus>('/rest/apStatus'); alovaInstance.Get<APSettingsType>('/rest/apSettings');
export const readAPSettings = () => alovaInstance.Get<APSettings>('/rest/apSettings'); export const updateAPSettings = (data: APSettingsType) =>
export const updateAPSettings = (data: APSettings) => alovaInstance.Post<APSettings>('/rest/apSettings', data); alovaInstance.Post<APSettingsType>('/rest/apSettings', data);

151
interface/src/api/app.ts Normal file
View File

@@ -0,0 +1,151 @@
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,33 +1,36 @@
import { xhrRequestAdapter } from '@alova/adapter-xhr'; import { type AlovaXHRResponse, xhrRequestAdapter } from '@alova/adapter-xhr';
import { createAlova } from 'alova'; import { createAlova } from 'alova';
import ReactHook from 'alova/react'; import ReactHook from 'alova/react';
import { unpack } from '../api/unpack';
import { unpack } from './unpack';
export const ACCESS_TOKEN = 'access_token'; export const ACCESS_TOKEN = 'access_token';
const host = window.location.host;
export const WEB_SOCKET_ROOT = 'ws://' + host + '/ws/';
export const EVENT_SOURCE_ROOT = 'http://' + host + '/es/';
export const alovaInstance = createAlova({ export const alovaInstance = createAlova({
statesHook: ReactHook, statesHook: ReactHook,
timeout: 3000, // 3 seconds but throwing a timeout error // timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
localCache: null, cacheFor: null, // disable cache
// localCache: { // cacheFor: {
// GET: { // GET: {
// mode: 'placeholder', // see https://alova.js.org/learning/response-cache/#cache-replaceholder-mode // mode: 'memory',
// expire: 2000 // expire: 60 * 10 * 1000 // 60 seconds in cache
// } // }
// }, // },
requestAdapter: xhrRequestAdapter(), requestAdapter: xhrRequestAdapter(),
beforeRequest(method) { beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) { if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization = 'Bearer ' + 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));
// });
}, },
responded: { responded: {
onSuccess: async (response) => { onSuccess: async (response: AlovaXHRResponse) => {
// if (response.status === 202) { // if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress // throw new Error('Wait'); // wifi scan in progress
// } else // } else
@@ -38,9 +41,9 @@ export const alovaInstance = createAlova({
} else if (response.status >= 400) { } else if (response.status >= 400) {
throw new Error(response.statusText); throw new Error(response.statusText);
} }
const data = await response.data; const data: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) { if (response.data instanceof ArrayBuffer) {
return unpack(data); return unpack(data) as ArrayBuffer;
} }
return data; return data;
} }

View File

@@ -1,6 +1,10 @@
import { alovaInstance } from './endpoints'; import type { MqttSettingsType, MqttStatusType } from 'types';
import type { MqttSettings, MqttStatus } from 'types';
export const readMqttStatus = () => alovaInstance.Get<MqttStatus>('/rest/mqttStatus'); import { alovaInstance } from './endpoints';
export const readMqttSettings = () => alovaInstance.Get<MqttSettings>('/rest/mqttSettings');
export const updateMqttSettings = (data: MqttSettings) => alovaInstance.Post<MqttSettings>('/rest/mqttSettings', data); export const readMqttStatus = () =>
alovaInstance.Get<MqttStatusType>('/rest/mqttStatus');
export const readMqttSettings = () =>
alovaInstance.Get<MqttSettingsType>('/rest/mqttSettings');
export const updateMqttSettings = (data: MqttSettingsType) =>
alovaInstance.Post<MqttSettingsType>('/rest/mqttSettings', data);

View File

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

View File

@@ -1,11 +1,14 @@
import type { NTPSettingsType, NTPStatusType, Time } from 'types';
import { alovaInstance } from './endpoints'; import { alovaInstance } from './endpoints';
import type { NTPSettings, NTPStatus, Time } from 'types';
export const readNTPStatus = () => alovaInstance.Get<NTPStatus>('/rest/ntpStatus'); export const readNTPStatus = () =>
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
export const readNTPSettings = () => export const readNTPSettings = () =>
alovaInstance.Get<NTPSettings>('/rest/ntpSettings', { alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {});
name: 'ntpSettings' export const updateNTPSettings = (data: NTPSettingsType) =>
}); alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);
export const updateNTPSettings = (data: NTPSettings) => alovaInstance.Post<NTPSettings>('/rest/ntpSettings', data);
export const updateTime = (data: Time) => alovaInstance.Post<Time>('/rest/time', data); export const updateTime = (data: Time) =>
alovaInstance.Post<Time>('/rest/time', data);

View File

@@ -1,10 +1,11 @@
import type { SecuritySettingsType, Token } from 'types';
import { alovaInstance } from './endpoints'; import { alovaInstance } from './endpoints';
import type { SecuritySettings, Token } from 'types'; export const readSecuritySettings = () =>
alovaInstance.Get<SecuritySettingsType>('/rest/securitySettings');
export const readSecuritySettings = () => alovaInstance.Get<SecuritySettings>('/rest/securitySettings'); export const updateSecuritySettings = (securitySettings: SecuritySettingsType) =>
export const updateSecuritySettings = (securitySettings: SecuritySettings) =>
alovaInstance.Post('/rest/securitySettings', securitySettings); alovaInstance.Post('/rest/securitySettings', securitySettings);
export const generateToken = (username?: string) => export const generateToken = (username?: string) =>

View File

@@ -1,33 +1,28 @@
import type { LogSettings, SystemStatus } from 'types';
import { alovaInstance, alovaInstanceGH } from './endpoints'; import { alovaInstance, alovaInstanceGH } from './endpoints';
import type { OTASettings, SystemStatus, LogSettings } from 'types';
// SystemStatus - also used to ping in Restart monitor for pinging // systemStatus - also used to ping in Restart monitor for pinging
export const readSystemStatus = () => alovaInstance.Get<SystemStatus>('/rest/systemStatus'); export const readSystemStatus = () =>
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
// commands
export const restart = () => alovaInstance.Post('/rest/restart');
export const partition = () => alovaInstance.Post('/rest/partition');
export const factoryReset = () => alovaInstance.Post('/rest/factoryReset');
// OTA
export const readOTASettings = () => alovaInstance.Get<OTASettings>(`/rest/otaSettings`);
export const updateOTASettings = (data: any) => alovaInstance.Post('/rest/otaSettings', data);
// SystemLog // SystemLog
export const readLogSettings = () => alovaInstance.Get<LogSettings>(`/rest/logSettings`); export const readLogSettings = () =>
export const updateLogSettings = (data: any) => alovaInstance.Post('/rest/logSettings', data); alovaInstance.Get<LogSettings>(`/rest/logSettings`);
export const fetchLog = () => alovaInstance.Post('/rest/fetchLog'); export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log');
// Get versions from github // Get versions from GitHub
export const getStableVersion = () => export const getStableVersion = () =>
alovaInstanceGH.Get('latest', { alovaInstanceGH.Get('latest', {
transformData(response: any) { transform(response: { data: { name: string } }) {
return response.data.name.substring(1); return response.data.name.substring(1);
} }
}); });
export const getDevVersion = () => export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', { alovaInstanceGH.Get('tags/latest', {
transformData(response: any) { transform(response: { data: { name: string } }) {
return response.data.name.split(/\s+/).splice(-1)[0].substring(1); return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
} }
}); });
@@ -36,7 +31,6 @@ export const uploadFile = (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, { return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000, // override timeout for uploading firmware - 1 minute timeout: 60000 // override timeout for uploading firmware - 1 minute
enableUpload: true
}); });
}; };

View File

@@ -38,7 +38,8 @@ try {
export class Unpackr { export class Unpackr {
constructor(options) { constructor(options) {
if (options) { if (options) {
if (options.useRecords === false && options.mapsAsObjects === undefined) options.mapsAsObjects = true; if (options.useRecords === false && options.mapsAsObjects === undefined)
options.mapsAsObjects = true;
if (options.sequential && options.trusted !== false) { if (options.sequential && options.trusted !== false) {
options.trusted = true; options.trusted = true;
if (!options.structures && options.useRecords != false) { if (!options.structures && options.useRecords != false) {
@@ -46,7 +47,8 @@ export class Unpackr {
if (!options.maxSharedStructures) options.maxSharedStructures = 0; if (!options.maxSharedStructures) options.maxSharedStructures = 0;
} }
} }
if (options.structures) options.structures.sharedLength = options.structures.length; if (options.structures)
options.structures.sharedLength = options.structures.length;
else if (options.getStructures) { else if (options.getStructures) {
(options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures (options.structures = []).uninitialized = true; // this is what we use to denote an uninitialized structures
options.structures.sharedLength = 0; options.structures.sharedLength = 0;
@@ -63,11 +65,14 @@ export class Unpackr {
// re-entrant execution, save the state and restore it after we do this unpack // re-entrant execution, save the state and restore it after we do this unpack
return saveState(() => { return saveState(() => {
clearSource(); clearSource();
return this ? this.unpack(source, options) : Unpackr.prototype.unpack.call(defaultOptions, source, options); return this
? this.unpack(source, options)
: Unpackr.prototype.unpack.call(defaultOptions, source, options);
}); });
} }
if (!source.buffer && source.constructor === ArrayBuffer) if (!source.buffer && source.constructor === ArrayBuffer)
source = typeof Buffer !== 'undefined' ? Buffer.from(source) : new Uint8Array(source); source =
typeof Buffer !== 'undefined' ? Buffer.from(source) : new Uint8Array(source);
if (typeof options === 'object') { if (typeof options === 'object') {
srcEnd = options.end || source.length; srcEnd = options.end || source.length;
position = options.start || 0; position = options.start || 0;
@@ -86,14 +91,21 @@ export class Unpackr {
// new ones // new ones
try { try {
dataView = dataView =
source.dataView || (source.dataView = new DataView(source.buffer, source.byteOffset, source.byteLength)); source.dataView ||
(source.dataView = new DataView(
source.buffer,
source.byteOffset,
source.byteLength
));
} catch (error) { } catch (error) {
// if it doesn't have a buffer, maybe it is the wrong type of object // if it doesn't have a buffer, maybe it is the wrong type of object
src = null; src = null;
if (source instanceof Uint8Array) throw error; if (source instanceof Uint8Array) throw error;
throw new Error( throw new Error(
'Source must be a Uint8Array or Buffer but was a ' + 'Source must be a Uint8Array or Buffer but was a ' +
(source && typeof source == 'object' ? source.constructor.name : typeof source) (source && typeof source == 'object'
? source.constructor.name
: typeof source)
); );
} }
if (this instanceof Unpackr) { if (this instanceof Unpackr) {
@@ -117,7 +129,9 @@ export class Unpackr {
try { try {
sequentialMode = true; sequentialMode = true;
const size = source.length; const size = source.length;
const value = this ? this.unpack(source, size) : defaultUnpackr.unpack(source, size); const value = this
? this.unpack(source, size)
: defaultUnpackr.unpack(source, size);
if (forEach) { if (forEach) {
if (forEach(value) === false) return; if (forEach(value) === false) return;
while (position < size) { while (position < size) {
@@ -145,9 +159,11 @@ export class Unpackr {
} }
_mergeStructures(loadedStructures, existingStructures) { _mergeStructures(loadedStructures, existingStructures) {
if (onLoadedStructures) loadedStructures = onLoadedStructures.call(this, loadedStructures); if (onLoadedStructures)
loadedStructures = onLoadedStructures.call(this, loadedStructures);
loadedStructures = loadedStructures || []; loadedStructures = loadedStructures || [];
if (Object.isFrozen(loadedStructures)) loadedStructures = loadedStructures.map((structure) => structure.slice(0)); if (Object.isFrozen(loadedStructures))
loadedStructures = loadedStructures.map((structure) => structure.slice(0));
for (let i = 0, l = loadedStructures.length; i < l; i++) { for (let i = 0, l = loadedStructures.length; i < l; i++) {
const structure = loadedStructures[i]; const structure = loadedStructures[i];
if (structure) { if (structure) {
@@ -162,7 +178,8 @@ export class Unpackr {
const existing = existingStructures[id]; const existing = existingStructures[id];
if (existing) { if (existing) {
if (structure) if (structure)
(loadedStructures.restoreStructures || (loadedStructures.restoreStructures = []))[id] = structure; (loadedStructures.restoreStructures ||
(loadedStructures.restoreStructures = []))[id] = structure;
loadedStructures[id] = existing; loadedStructures[id] = existing;
} }
} }
@@ -181,10 +198,16 @@ export function checkedRead(options: any) {
try { try {
if (!currentUnpackr.trusted && !sequentialMode) { if (!currentUnpackr.trusted && !sequentialMode) {
const sharedLength = currentStructures.sharedLength || 0; const sharedLength = currentStructures.sharedLength || 0;
if (sharedLength < currentStructures.length) currentStructures.length = sharedLength; if (sharedLength < currentStructures.length)
currentStructures.length = sharedLength;
} }
let result; let result;
if (currentUnpackr.randomAccessStructure && src[position] < 0x40 && src[position] >= 0x20 && readStruct) { if (
currentUnpackr.randomAccessStructure &&
src[position] < 0x40 &&
src[position] >= 0x20 &&
readStruct
) {
result = readStruct(src, position, srcEnd, currentUnpackr); result = readStruct(src, position, srcEnd, currentUnpackr);
src = null; // dispose of this so that recursive unpack calls don't save state src = null; // dispose of this so that recursive unpack calls don't save state
if (!(options && options.lazy) && result) result = result.toJSON(); if (!(options && options.lazy) && result) result = result.toJSON();
@@ -198,7 +221,8 @@ export function checkedRead(options: any) {
if (position == srcEnd) { if (position == srcEnd) {
// finished reading this source, cleanup references // finished reading this source, cleanup references
if (currentStructures && currentStructures.restoreStructures) restoreStructures(); if (currentStructures && currentStructures.restoreStructures)
restoreStructures();
currentStructures = null; currentStructures = null;
src = null; src = null;
if (referenceMap) referenceMap = null; if (referenceMap) referenceMap = null;
@@ -208,10 +232,9 @@ export function checkedRead(options: any) {
} else if (!sequentialMode) { } else if (!sequentialMode) {
let jsonView; let jsonView;
try { try {
jsonView = JSON.stringify(result, (_, value) => (typeof value === 'bigint' ? `${value}n` : value)).slice( jsonView = JSON.stringify(result, (_, value) =>
0, typeof value === 'bigint' ? `${value}n` : value
100 ).slice(0, 100);
);
} catch (error) { } catch (error) {
jsonView = '(JSON view not available ' + error + ')'; jsonView = '(JSON view not available ' + error + ')';
} }
@@ -220,9 +243,14 @@ export function checkedRead(options: any) {
// else more to read, but we are reading sequentially, so don't clear source yet // else more to read, but we are reading sequentially, so don't clear source yet
return result; return result;
} catch (error) { } catch (error) {
if (currentStructures && currentStructures.restoreStructures) restoreStructures(); if (currentStructures && currentStructures.restoreStructures)
restoreStructures();
clearSource(); clearSource();
if (error instanceof RangeError || error.message.startsWith('Unexpected end of buffer') || position > srcEnd) { if (
error instanceof RangeError ||
error.message.startsWith('Unexpected end of buffer') ||
position > srcEnd
) {
error.incomplete = true; error.incomplete = true;
} }
throw error; throw error;
@@ -243,7 +271,8 @@ export function read() {
if (token < 0x40) return token; if (token < 0x40) return token;
else { else {
const structure = const structure =
currentStructures[token & 0x3f] || (currentUnpackr.getStructures && loadStructures()[token & 0x3f]); currentStructures[token & 0x3f] ||
(currentUnpackr.getStructures && loadStructures()[token & 0x3f]);
if (structure) { if (structure) {
if (!structure.read) { if (!structure.read) {
structure.read = createStructureReader(structure, token & 0x3f); structure.read = createStructureReader(structure, token & 0x3f);
@@ -282,7 +311,10 @@ export function read() {
// fixstr // fixstr
const length = token - 0xa0; const length = token - 0xa0;
if (srcStringEnd >= position) { if (srcStringEnd >= position) {
return srcString.slice(position - srcStringStart, (position += length) - srcStringStart); return srcString.slice(
position - srcStringStart,
(position += length) - srcStringStart
);
} }
if (srcStringEnd == 0 && srcEnd < 140) { if (srcStringEnd == 0 && srcEnd < 140) {
// for small blocks, avoiding the overhead of the extract call is helpful // for small blocks, avoiding the overhead of the extract call is helpful
@@ -298,8 +330,16 @@ export function read() {
case 0xc1: case 0xc1:
if (bundledStrings) { if (bundledStrings) {
value = read(); // followed by the length of the string in characters (not bytes!) value = read(); // followed by the length of the string in characters (not bytes!)
if (value > 0) return bundledStrings[1].slice(bundledStrings.position1, (bundledStrings.position1 += value)); if (value > 0)
else return bundledStrings[0].slice(bundledStrings.position0, (bundledStrings.position0 -= value)); return bundledStrings[1].slice(
bundledStrings.position1,
(bundledStrings.position1 += value)
);
else
return bundledStrings[0].slice(
bundledStrings.position0,
(bundledStrings.position0 -= value)
);
} }
return C1; // "never-used", return special object to denote that return C1; // "never-used", return special object to denote that
case 0xc2: case 0xc2:
@@ -338,7 +378,8 @@ export function read() {
value = dataView.getFloat32(position); value = dataView.getFloat32(position);
if (currentUnpackr.useFloat32 > 2) { if (currentUnpackr.useFloat32 > 2) {
// this does rounding of numbers that were encoded in 32-bit float to nearest significant decimal digit that could be preserved // this does rounding of numbers that were encoded in 32-bit float to nearest significant decimal digit that could be preserved
const multiplier = mult10[((src[position] & 0x7f) << 1) | (src[position + 1] >> 7)]; const multiplier =
mult10[((src[position] & 0x7f) << 1) | (src[position + 1] >> 7)];
position += 4; position += 4;
return ((multiplier * value + (value > 0 ? 0.5 : -0.5)) >> 0) / multiplier; return ((multiplier * value + (value > 0 ? 0.5 : -0.5)) >> 0) / multiplier;
} }
@@ -391,7 +432,8 @@ export function read() {
value = dataView.getBigInt64(position).toString(); value = dataView.getBigInt64(position).toString();
} else if (currentUnpackr.int64AsType === 'auto') { } else if (currentUnpackr.int64AsType === 'auto') {
value = dataView.getBigInt64(position); value = dataView.getBigInt64(position);
if (value >= BigInt(-2) << BigInt(52) && value <= BigInt(2) << BigInt(52)) value = Number(value); if (value >= BigInt(-2) << BigInt(52) && value <= BigInt(2) << BigInt(52))
value = Number(value);
} else value = dataView.getBigInt64(position); } else value = dataView.getBigInt64(position);
position += 8; position += 8;
return value; return value;
@@ -433,7 +475,10 @@ export function read() {
// str 8 // str 8
value = src[position++]; value = src[position++];
if (srcStringEnd >= position) { if (srcStringEnd >= position) {
return srcString.slice(position - srcStringStart, (position += value) - srcStringStart); return srcString.slice(
position - srcStringStart,
(position += value) - srcStringStart
);
} }
return readString8(value); return readString8(value);
case 0xda: case 0xda:
@@ -441,7 +486,10 @@ export function read() {
value = dataView.getUint16(position); value = dataView.getUint16(position);
position += 2; position += 2;
if (srcStringEnd >= position) { if (srcStringEnd >= position) {
return srcString.slice(position - srcStringStart, (position += value) - srcStringStart); return srcString.slice(
position - srcStringStart,
(position += value) - srcStringStart
);
} }
return readString16(value); return readString16(value);
case 0xdb: case 0xdb:
@@ -449,7 +497,10 @@ export function read() {
value = dataView.getUint32(position); value = dataView.getUint32(position);
position += 4; position += 4;
if (srcStringEnd >= position) { if (srcStringEnd >= position) {
return srcString.slice(position - srcStringStart, (position += value) - srcStringStart); return srcString.slice(
position - srcStringStart,
(position += value) - srcStringStart
);
} }
return readString32(value); return readString32(value);
case 0xdc: case 0xdc:
@@ -504,7 +555,8 @@ function createStructureReader(structure, firstId) {
.join(',') + .join(',') +
'})}' '})}'
)(read)); )(read));
if (structure.highByte === 0) structure.read = createSecondByteReader(firstId, structure.read); if (structure.highByte === 0)
structure.read = createSecondByteReader(firstId, structure.read);
return readObject(); // second byte is already read, if there is one so immediately read object return readObject(); // second byte is already read, if there is one so immediately read object
} }
const object = {}; const object = {};
@@ -527,7 +579,8 @@ const createSecondByteReader = (firstId, read0) =>
function () { function () {
const highByte = src[position++]; const highByte = src[position++];
if (highByte === 0) return read0(); if (highByte === 0) return read0();
const id = firstId < 32 ? -(firstId + (highByte << 5)) : firstId + (highByte << 5); const id =
firstId < 32 ? -(firstId + (highByte << 5)) : firstId + (highByte << 5);
const structure = currentStructures[id] || loadStructures()[id]; const structure = currentStructures[id] || loadStructures()[id];
if (!structure) { if (!structure) {
throw new Error('Record id is not defined for ' + id); throw new Error('Record id is not defined for ' + id);
@@ -542,7 +595,10 @@ export function loadStructures() {
src = null; src = null;
return currentUnpackr.getStructures(); return currentUnpackr.getStructures();
}); });
return (currentStructures = currentUnpackr._mergeStructures(loadedStructures, currentStructures)); return (currentStructures = currentUnpackr._mergeStructures(
loadedStructures,
currentStructures
));
} }
var readFixedString = readStringJS; var readFixedString = readStringJS;
@@ -563,7 +619,11 @@ export function setExtractor(extractStrings) {
if (string == null) { if (string == null) {
if (bundledStrings) return readStringJS(length); if (bundledStrings) return readStringJS(length);
const byteOffset = src.byteOffset; const byteOffset = src.byteOffset;
const extraction = extractStrings(position - headerLength + byteOffset, srcEnd + byteOffset, src.buffer); const extraction = extractStrings(
position - headerLength + byteOffset,
srcEnd + byteOffset,
src.buffer
);
if (typeof extraction == 'string') { if (typeof extraction == 'string') {
string = extraction; string = extraction;
strings = EMPTY_ARRAY; strings = EMPTY_ARRAY;
@@ -593,7 +653,8 @@ function readStringJS(length) {
if (length < 16) { if (length < 16) {
if ((result = shortStringInJS(length))) return result; if ((result = shortStringInJS(length))) return result;
} }
if (length > 64 && decoder) return decoder.decode(src.subarray(position, (position += length))); if (length > 64 && decoder)
return decoder.decode(src.subarray(position, (position += length)));
const end = position + length; const end = position + length;
const units = []; const units = [];
result = ''; result = '';
@@ -616,7 +677,8 @@ function readStringJS(length) {
const byte2 = src[position++] & 0x3f; const byte2 = src[position++] & 0x3f;
const byte3 = src[position++] & 0x3f; const byte3 = src[position++] & 0x3f;
const byte4 = src[position++] & 0x3f; const byte4 = src[position++] & 0x3f;
let unit = ((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4; let unit =
((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4;
if (unit > 0xffff) { if (unit > 0xffff) {
unit -= 0x10000; unit -= 0x10000;
units.push(((unit >>> 10) & 0x3ff) | 0xd800); units.push(((unit >>> 10) & 0x3ff) | 0xd800);
@@ -810,7 +872,8 @@ function shortStringInJS(length) {
position -= 14; position -= 14;
return; return;
} }
if (length < 15) return fromCharCode(a, b, c, d, e, f, g, h, i, j, k, l, m, n); if (length < 15)
return fromCharCode(a, b, c, d, e, f, g, h, i, j, k, l, m, n);
const o = src[position++]; const o = src[position++];
if ((o & 0x80) > 0) { if ((o & 0x80) > 0) {
position -= 15; position -= 15;
@@ -862,14 +925,17 @@ function readExt(length) {
const type = src[position++]; const type = src[position++];
if (currentExtensions[type]) { if (currentExtensions[type]) {
let end; let end;
return currentExtensions[type](src.subarray(position, (end = position += length)), (readPosition) => { return currentExtensions[type](
src.subarray(position, (end = position += length)),
(readPosition) => {
position = readPosition; position = readPosition;
try { try {
return read(); return read();
} finally { } finally {
position = end; position = end;
} }
}); }
);
} else throw new Error('Unknown extension type ' + type); } else throw new Error('Unknown extension type ' + type);
} }
@@ -881,14 +947,20 @@ function readKey() {
length = length - 0xa0; length = length - 0xa0;
if (srcStringEnd >= position) if (srcStringEnd >= position)
// if it has been extracted, must use it (and faster anyway) // if it has been extracted, must use it (and faster anyway)
return srcString.slice(position - srcStringStart, (position += length) - srcStringStart); return srcString.slice(
position - srcStringStart,
(position += length) - srcStringStart
);
else if (!(srcStringEnd == 0 && srcEnd < 180)) return readFixedString(length); else if (!(srcStringEnd == 0 && srcEnd < 180)) return readFixedString(length);
} else { } else {
// not cacheable, go back and do a standard read // not cacheable, go back and do a standard read
position--; position--;
return read().toString(); return read().toString();
} }
const key = ((length << 5) ^ (length > 1 ? dataView.getUint16(position) : length > 0 ? src[position] : 0)) & 0xfff; const key =
((length << 5) ^
(length > 1 ? dataView.getUint16(position) : length > 0 ? src[position] : 0)) &
0xfff;
let entry = keyCache[key]; let entry = keyCache[key];
let checkPosition = position; let checkPosition = position;
let end = position + length - 3; let end = position + length - 3;
@@ -947,7 +1019,8 @@ const recordDefinition = (id, highByte) => {
} }
const existingStructure = currentStructures[id]; const existingStructure = currentStructures[id];
if (existingStructure && existingStructure.isShared) { if (existingStructure && existingStructure.isShared) {
(currentStructures.restoreStructures || (currentStructures.restoreStructures = []))[id] = existingStructure; (currentStructures.restoreStructures ||
(currentStructures.restoreStructures = []))[id] = existingStructure;
} }
currentStructures[id] = structure; currentStructures[id] = structure;
structure.read = createStructureReader(structure, firstByte); structure.read = createStructureReader(structure, firstByte);
@@ -1009,7 +1082,8 @@ export const typedArrays = [
currentExtensions[0x74] = (data) => { currentExtensions[0x74] = (data) => {
const typeCode = data[0]; const typeCode = data[0];
const typedArrayName = typedArrays[typeCode]; const typedArrayName = typedArrays[typeCode];
if (!typedArrayName) throw new Error('Could not find typed array for code ' + typeCode); if (!typedArrayName)
throw new Error('Could not find typed array for code ' + typeCode);
// we have to always slice/copy here to get a new ArrayBuffer that is word/byte aligned // we have to always slice/copy here to get a new ArrayBuffer that is word/byte aligned
return new glbl[typedArrayName](Uint8Array.prototype.slice.call(data, 1).buffer); return new glbl[typedArrayName](Uint8Array.prototype.slice.call(data, 1).buffer);
}; };
@@ -1033,11 +1107,20 @@ currentExtensions[0x62] = (data) => {
currentExtensions[0xff] = (data) => { currentExtensions[0xff] = (data) => {
// 32-bit date extension // 32-bit date extension
if (data.length == 4) return new Date((data[0] * 0x1000000 + (data[1] << 16) + (data[2] << 8) + data[3]) * 1000); if (data.length == 4)
return new Date(
(data[0] * 0x1000000 + (data[1] << 16) + (data[2] << 8) + data[3]) * 1000
);
else if (data.length == 8) else if (data.length == 8)
return new Date( return new Date(
((data[0] << 22) + (data[1] << 14) + (data[2] << 6) + (data[3] >> 2)) / 1000000 + ((data[0] << 22) + (data[1] << 14) + (data[2] << 6) + (data[3] >> 2)) /
((data[3] & 0x3) * 0x100000000 + data[4] * 0x1000000 + (data[5] << 16) + (data[6] << 8) + data[7]) * 1000 1000000 +
((data[3] & 0x3) * 0x100000000 +
data[4] * 0x1000000 +
(data[5] << 16) +
(data[6] << 8) +
data[7]) *
1000
); );
else if (data.length == 12) else if (data.length == 12)
return new Date( return new Date(
@@ -1070,7 +1153,10 @@ function saveState(callback) {
const savedSrc = new Uint8Array(src.slice(0, srcEnd)); // we copy the data in case it changes while external data is processed const savedSrc = new Uint8Array(src.slice(0, srcEnd)); // we copy the data in case it changes while external data is processed
const savedStructures = currentStructures; const savedStructures = currentStructures;
const savedStructuresContents = currentStructures.slice(0, currentStructures.length); const savedStructuresContents = currentStructures.slice(
0,
currentStructures.length
);
const savedPackr = currentUnpackr; const savedPackr = currentUnpackr;
const savedSequentialMode = sequentialMode; const savedSequentialMode = sequentialMode;
const value = callback(); const value = callback();
@@ -1122,7 +1208,10 @@ const u8Array = new Uint8Array(f32Array.buffer, 0, 4);
export function roundFloat32(float32Number) { export function roundFloat32(float32Number) {
f32Array[0] = float32Number; f32Array[0] = float32Number;
const multiplier = mult10[((u8Array[3] & 0x7f) << 1) | (u8Array[2] >> 7)]; const multiplier = mult10[((u8Array[3] & 0x7f) << 1) | (u8Array[2] >> 7)];
return ((multiplier * float32Number + (float32Number > 0 ? 0.5 : -0.5)) >> 0) / multiplier; return (
((multiplier * float32Number + (float32Number > 0 ? 0.5 : -0.5)) >> 0) /
multiplier
);
} }
export function setReadStruct(updatedReadStruct, loadedStructs, saveState) { export function setReadStruct(updatedReadStruct, loadedStructs, saveState) {
readStruct = updatedReadStruct; readStruct = updatedReadStruct;

View File

@@ -1,29 +1,41 @@
import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Button, Typography, Box } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table';
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 { useTheme } from '@table-library/react-table-library/theme';
// eslint-disable-next-line import/named import { updateState, useRequest } from 'alova/client';
import { updateState, useRequest } from 'alova'; import {
import { useState, useCallback } from 'react'; BlockNavigation,
import { useBlocker } from 'react-router-dom'; ButtonRow,
FormLoader,
import { toast } from 'react-toastify'; SectionContent,
useLayoutTitle
import SettingsCustomEntitiesDialog from './SettingsCustomEntitiesDialog'; } from 'components';
import * as EMSESP from './api';
import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import { entityItemValidation } from './validators';
import type { EntityItem } from './types';
import type { FC } from 'react';
import { ButtonRow, FormLoader, SectionContent, BlockNavigation } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
const SettingsCustomEntities: FC = () => { 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 { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0); const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0); const blocker = useBlocker(numChanges !== 0);
@@ -31,16 +43,26 @@ const SettingsCustomEntities: FC = () => {
const [creating, setCreating] = useState<boolean>(false); const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.CUSTOM_ENTITIES(0));
const { const {
data: entities, data: entities,
send: fetchEntities, send: fetchEntities,
error error
} = useRequest(EMSESP.readCustomEntities, { } = useRequest(readCustomEntities, {
initialData: [], initialData: []
force: true
}); });
const { send: writeEntities } = useRequest((data) => EMSESP.writeCustomEntities(data), { immediate: false }); useInterval(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
}, 3000);
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
function hasEntityChanged(ei: EntityItem) { function hasEntityChanged(ei: EntityItem) {
return ( return (
@@ -103,15 +125,10 @@ const SettingsCustomEntities: FC = () => {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
.td { .td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
&:hover .td { &:hover .td {
border-top: 1px solid #177ac9; background-color: #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
} }
` `
}); });
@@ -137,8 +154,8 @@ const SettingsCustomEntities: FC = () => {
.then(() => { .then(() => {
toast.success(LL.ENTITIES_UPDATED()); toast.success(LL.ENTITIES_UPDATED());
}) })
.catch((err) => { .catch((error: Error) => {
toast.error(err.message); toast.error(error.message);
}) })
.finally(async () => { .finally(async () => {
await fetchEntities(); await fetchEntities();
@@ -164,11 +181,15 @@ const SettingsCustomEntities: FC = () => {
const onDialogSave = (updatedItem: EntityItem) => { const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
updateState('entities', (data) => {
const new_data = creating 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)); ...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); setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data; return new_data;
}); });
@@ -193,12 +214,13 @@ const SettingsCustomEntities: FC = () => {
setDialogOpen(true); setDialogOpen(true);
}; };
function formatValue(value: any, uom: number) { function formatValue(value: unknown, uom: number) {
return value === undefined || uom === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]) ? new Intl.NumberFormat().format(value) +
: value; (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
: (value as string);
} }
function showHex(value: number, digit: number) { function showHex(value: number, digit: number) {
@@ -211,8 +233,16 @@ const SettingsCustomEntities: FC = () => {
} }
return ( return (
<Table data={{ nodes: entities.filter((ei) => !ei.deleted) }} theme={entity_theme} layout={{ custom: true }}> <Table
{(tableList: any) => ( data={{
nodes: entities
.filter((ei) => !ei.deleted)
.sort((a, b) => a.name.localeCompare(b.name))
}}
theme={entity_theme}
layout={{ custom: true }}
>
{(tableList: EntityItem[]) => (
<> <>
<Header> <Header>
<HeaderRow> <HeaderRow>
@@ -220,8 +250,8 @@ const SettingsCustomEntities: FC = () => {
<HeaderCell stiff>{LL.ID_OF(LL.DEVICE())}</HeaderCell> <HeaderCell stiff>{LL.ID_OF(LL.DEVICE())}</HeaderCell>
<HeaderCell stiff>{LL.ID_OF(LL.TYPE(1))}</HeaderCell> <HeaderCell stiff>{LL.ID_OF(LL.TYPE(1))}</HeaderCell>
<HeaderCell stiff>{LL.OFFSET()}</HeaderCell> <HeaderCell stiff>{LL.OFFSET()}</HeaderCell>
<HeaderCell stiff>{LL.TYPE(1)}</HeaderCell> <HeaderCell stiff>{LL.TYPE(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(1)}</HeaderCell> <HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
</HeaderRow> </HeaderRow>
</Header> </Header>
<Body> <Body>
@@ -229,12 +259,18 @@ const SettingsCustomEntities: FC = () => {
<Row key={ei.name} item={ei} onClick={() => editEntityItem(ei)}> <Row key={ei.name} item={ei} onClick={() => editEntityItem(ei)}>
<Cell> <Cell>
{ei.name}&nbsp; {ei.name}&nbsp;
{ei.writeable && <EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />} {ei.writeable && (
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
)}
</Cell>
<Cell>
{ei.ram === 1 ? '' : showHex(ei.device_id as number, 2)}
</Cell> </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 ? '' : showHex(ei.type_id as number, 3)}</Cell>
<Cell>{ei.ram === 1 ? '' : ei.offset}</Cell> <Cell>{ei.ram === 1 ? '' : ei.offset}</Cell>
<Cell>{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}</Cell> <Cell>
{ei.ram === 1 ? 'RAM' : DeviceValueTypeNames[ei.value_type]}
</Cell>
<Cell>{formatValue(ei.value, ei.uom)}</Cell> <Cell>{formatValue(ei.value, ei.uom)}</Cell>
</Row> </Row>
))} ))}
@@ -246,10 +282,10 @@ const SettingsCustomEntities: FC = () => {
}; };
return ( return (
<SectionContent title={LL.CUSTOM_ENTITIES(0)} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
<Box mb={2} color="warning.main"> <Box mb={2} color="warning.main">
<Typography variant="body2">{LL.ENTITIES_HELP_1()}</Typography> <Typography variant="body1">{LL.ENTITIES_HELP_1()}.</Typography>
</Box> </Box>
{renderEntity()} {renderEntity()}
@@ -261,15 +297,20 @@ const SettingsCustomEntities: FC = () => {
onClose={onDialogClose} onClose={onDialogClose}
onSave={onDialogSave} onSave={onDialogSave}
selectedItem={selectedEntityItem} selectedItem={selectedEntityItem}
validator={entityItemValidation()} validator={entityItemValidation(entities, selectedEntityItem)}
/> />
)} )}
<Box display="flex" flexWrap="wrap"> <Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}> <Box flexGrow={1}>
{numChanges > 0 && ( {numChanges > 0 && (
<ButtonRow> <ButtonRow>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={onDialogCancel} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
@@ -284,18 +325,18 @@ const SettingsCustomEntities: FC = () => {
)} )}
</Box> </Box>
<Box flexWrap="nowrap" whiteSpace="nowrap"> <Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow> <Button
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={fetchEntities}> startIcon={<AddIcon />}
{LL.REFRESH()} variant="outlined"
</Button> color="primary"
<Button startIcon={<AddIcon />} variant="outlined" color="primary" onClick={addEntityItem}> onClick={addEntityItem}
>
{LL.ADD(0)} {LL.ADD(0)}
</Button> </Button>
</ButtonRow>
</Box> </Box>
</Box> </Box>
</SectionContent> </SectionContent>
); );
}; };
export default SettingsCustomEntities; export default CustomEntities;

View File

@@ -0,0 +1,342 @@
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,50 +1,70 @@
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 CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SaveIcon from '@mui/icons-material/Save';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Button,
Typography,
Box, Box,
MenuItem, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
InputAdornment,
Link,
MenuItem,
TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Grid, Typography
TextField,
Link,
InputAdornment
} from '@mui/material'; } from '@mui/material';
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'; import Grid from '@mui/material/Grid2';
import { useTheme } from '@table-library/react-table-library/theme';
import { useRequest } from 'alova';
import { useState, useEffect, useCallback } from 'react';
import { useBlocker, useLocation } from 'react-router-dom';
import { toast } from 'react-toastify';
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 EntityMaskToggle from './EntityMaskToggle';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
import SettingsCustomizationDialog from './SettingsCustomizationDialog';
import * as EMSESP from './api';
import { DeviceEntityMask } from './types'; import { DeviceEntityMask } from './types';
import type { DeviceShort, DeviceEntity } from './types'; import type { APIcall, Device, DeviceEntity } from './types';
import type { FC } from 'react';
import { dialogStyle } from 'CustomTheme';
import * as SystemApi from 'api/system';
import { ButtonRow, SectionContent, MessageBox, BlockNavigation } from 'components';
import RestartMonitor from 'framework/system/RestartMonitor';
import { useI18nContext } from 'i18n/i18n-react';
export const APIURL = window.location.origin + '/api/'; export const APIURL = window.location.origin + '/api/';
const SettingsCustomization: FC = () => { const Customizations = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0); const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0); const blocker = useBlocker(numChanges !== 0);
@@ -57,38 +77,72 @@ const SettingsCustomization: FC = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>(); const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [rename, setRename] = useState<boolean>(false);
// fetch devices first useLayoutTitle(LL.CUSTOMIZATIONS());
const { data: devices } = useRequest(EMSESP.readDevices);
// const { state } = useLocation(); // fetch devices first from coreData
const [selectedDevice, setSelectedDevice] = useState<number>(useLocation().state || -1); 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 [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
const { send: resetCustomizations } = useRequest(EMSESP.resetCustomizations(), { const { send: sendResetCustomizations } = useRequest(resetCustomizations(), {
immediate: false immediate: false
}); });
const { send: writeCustomizationEntities } = useRequest((data) => EMSESP.writeCustomizationEntities(data), { const { send: sendDeviceName } = useRequest(
(data: { id: number; name: string }) => writeDeviceName(data),
{
immediate: false immediate: false
}); }
);
const { send: readDeviceEntities, onSuccess: onSuccess } = useRequest((data) => EMSESP.readDeviceEntities(data), { const { send: sendCustomizationEntities } = useRequest(
(data: { id: number; entity_ids: string[] }) => writeCustomizationEntities(data),
{
immediate: false
}
);
const { send: sendDeviceEntities } = useRequest(
(data: number) => readDeviceEntities(data),
{
initialData: [], initialData: [],
immediate: false immediate: false
}); }
).onSuccess((event) => {
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 })));
};
onSuccess((event) => {
setOriginalSettings(event.data); setOriginalSettings(event.data);
}); });
const { send: restartCommand } = useRequest(SystemApi.restart(), { const setOriginalSettings = (data: DeviceEntity[]) => {
immediate: false 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({ const entities_theme = useTheme({
Table: ` Table: `
@@ -136,10 +190,7 @@ const SettingsCustomization: FC = () => {
} }
&:hover .td { &:hover .td {
border-top: 1px solid #177ac9; border-top: 1px solid #177ac9;
border-bottom: 1px solid #177ac9; background-color: #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
} }
`, `,
Cell: ` Cell: `
@@ -159,7 +210,12 @@ const SettingsCustomization: FC = () => {
}); });
function hasEntityChanged(de: DeviceEntity) { 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; return (
(de?.cn || '') !== (de?.o_cn || '') ||
de.m !== de.o_m ||
de.ma !== de.o_ma ||
de.mi !== de.o_mi
);
} }
useEffect(() => { useEffect(() => {
@@ -182,28 +238,21 @@ const SettingsCustomization: FC = () => {
useEffect(() => { useEffect(() => {
if (devices && selectedDevice !== -1) { if (devices && selectedDevice !== -1) {
void readDeviceEntities(selectedDevice); void sendDeviceEntities(selectedDevice);
const id = devices.devices.findIndex((d) => d.i === selectedDevice); const index = devices.devices.findIndex((d) => d.id === selectedDevice);
if (id === -1) { if (index === -1) {
setSelectedDevice(-1); setSelectedDevice(-1);
setSelectedDeviceName(''); setSelectedDeviceTypeNameURL('');
} else { } else {
setSelectedDeviceName(devices.devices[id].tn || ''); setSelectedDeviceTypeNameURL(devices.devices[index].url || '');
setSelectedDeviceName(devices.devices[index].n);
setNumChanges(0); setNumChanges(0);
setRestartNeeded(false); setRestartNeeded(false);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [devices, selectedDevice]); }, [devices, selectedDevice]);
const restart = async () => { function formatValue(value: unknown) {
await restartCommand().catch((error) => {
toast.error(error.message);
});
setRestarting(true);
};
function formatValue(value: any) {
if (typeof value === 'number') { if (typeof value === 'number') {
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat().format(value);
} else if (value === undefined) { } else if (value === undefined) {
@@ -211,12 +260,21 @@ const SettingsCustomization: FC = () => {
} else if (typeof value === 'boolean') { } else if (typeof value === 'boolean') {
return value ? 'true' : 'false'; return value ? 'true' : 'false';
} }
return value; return value as string;
} }
const formatName = (de: DeviceEntity, withShortname: boolean) => const formatName = (de: DeviceEntity, withShortname: boolean) =>
(de.n && de.n[0] === '!' ? LL.COMMAND(1) + ': ' + de.n.slice(1) : de.cn && de.cn !== '' ? de.cn : de.n) + (de.n && de.n[0] === '!'
(withShortname ? ' ' + de.id : ''); ? 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[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -247,7 +305,8 @@ const SettingsCustomization: FC = () => {
}; };
const filter_entity = (de: DeviceEntity) => const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && formatName(de, true).includes(search.toLocaleLowerCase()); (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).includes(search);
const maskDisabled = (set: boolean) => { const maskDisabled = (set: boolean) => {
setDeviceEntities( setDeviceEntities(
@@ -256,8 +315,14 @@ const SettingsCustomization: FC = () => {
return { return {
...de, ...de,
m: set m: set
? de.m | (DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE) ? de.m |
: de.m & ~(DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE) (DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
}; };
} else { } else {
return de; return de;
@@ -268,10 +333,10 @@ const SettingsCustomization: FC = () => {
const resetCustomization = async () => { const resetCustomization = async () => {
try { try {
await resetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
} catch (error) { } catch (error) {
toast.error(error.message); toast.error((error as Error).message);
} finally { } finally {
setConfirmReset(false); setConfirmReset(false);
} }
@@ -282,7 +347,11 @@ const SettingsCustomization: FC = () => {
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = (updatedItem: DeviceEntity) => {
setDeviceEntities(deviceEntities?.map((de) => (de.id === updatedItem.id ? { ...de, ...updatedItem } : de))); setDeviceEntities(
deviceEntities?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
)
);
}; };
const onDialogSave = (updatedItem: DeviceEntity) => { const onDialogSave = (updatedItem: DeviceEntity) => {
@@ -324,7 +393,10 @@ const SettingsCustomization: FC = () => {
return; return;
} }
await writeCustomizationEntities({ id: selectedDevice, entity_ids: masked_entities }).catch((error) => { await sendCustomizationEntities({
id: selectedDevice,
entity_ids: masked_entities
}).catch((error: Error) => {
if (error.message === 'Reboot required') { if (error.message === 'Reboot required') {
setRestartNeeded(true); setRestartNeeded(true);
} else { } else {
@@ -335,31 +407,90 @@ const SettingsCustomization: FC = () => {
} }
}; };
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 = () => ( const renderDeviceList = () => (
<> <>
<Box mb={1} color="warning.main"> <Box mb={1} color="warning.main">
<Typography variant="body2">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography> <Typography variant="body1">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
</Box> </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 <TextField
name="device" name="device"
label={LL.EMS_DEVICE()} label={LL.EMS_DEVICE()}
variant="outlined" variant="outlined"
fullWidth
value={selectedDevice} value={selectedDevice}
disabled={numChanges !== 0} disabled={numChanges !== 0}
onChange={(e) => setSelectedDevice(parseInt(e.target.value))} onChange={(e) => setSelectedDevice(parseInt(e.target.value))}
margin="normal" margin="normal"
style={{ minWidth: '50%' }}
select select
> >
<MenuItem disabled key={-1} value={-1}> <MenuItem disabled key={-1} value={-1}>
{LL.SELECT_DEVICE()}... {LL.SELECT_DEVICE()}...
</MenuItem> </MenuItem>
{devices.devices.map((device: DeviceShort) => ( {devices.devices.map(
<MenuItem key={device.i} value={device.i}> (device: Device) =>
{device.s} device.id < 90 && (
<MenuItem key={device.id} value={device.id}>
{device.n}&nbsp;({device.tn})
</MenuItem> </MenuItem>
))} )
)}
</TextField> </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>
</> </>
); );
@@ -370,15 +501,27 @@ const SettingsCustomization: FC = () => {
<> <>
<Box color="warning.main"> <Box color="warning.main">
<Typography variant="body2" mt={1}> <Typography variant="body2" mt={1}>
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}&nbsp;&nbsp; <OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}&nbsp;&nbsp; &nbsp;&nbsp;
<OptionIcon type="api_mqtt_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_4()}&nbsp;&nbsp; <OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()}&nbsp;&nbsp; &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()} <OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
</Typography> </Typography>
</Box> </Box>
<Grid container mb={1} mt={0} spacing={1} direction="row" justifyContent="flex-start" alignItems="center"> <Grid
<Grid item xs={2}> container
mb={1}
mt={0}
spacing={2}
direction="row"
justifyContent="flex-start"
alignItems="center"
>
<Grid>
<TextField <TextField
size="small" size="small"
variant="outlined" variant="outlined"
@@ -386,21 +529,23 @@ const SettingsCustomization: FC = () => {
onChange={(event) => { onChange={(event) => {
setSearch(event.target.value); setSearch(event.target.value);
}} }}
InputProps={{ slotProps={{
input: {
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
<SearchIcon color="primary" sx={{ fontSize: 16 }} /> <SearchIcon color="primary" sx={{ fontSize: 16 }} />
</InputAdornment> </InputAdornment>
) )
}
}} }}
/> />
</Grid> </Grid>
<Grid item> <Grid>
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(selectedFilters)} value={getMaskString(selectedFilters)}
onChange={(event, mask) => { onChange={(event, mask: string[]) => {
setSelectedFilters(getMaskNumber(mask)); setSelectedFilters(getMaskNumber(mask));
}} }}
> >
@@ -421,7 +566,7 @@ const SettingsCustomization: FC = () => {
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
</Grid> </Grid>
<Grid item> <Grid>
<Button <Button
size="small" size="small"
sx={{ fontSize: 10 }} sx={{ fontSize: 10 }}
@@ -434,7 +579,7 @@ const SettingsCustomization: FC = () => {
<OptionIcon type="web_exclude" isSet={false} /> <OptionIcon type="web_exclude" isSet={false} />
</Button> </Button>
</Grid> </Grid>
<Grid item> <Grid>
<Button <Button
size="small" size="small"
sx={{ fontSize: 10 }} sx={{ fontSize: 10 }}
@@ -447,14 +592,19 @@ const SettingsCustomization: FC = () => {
<OptionIcon type="web_exclude" isSet={true} /> <OptionIcon type="web_exclude" isSet={true} />
</Button> </Button>
</Grid> </Grid>
<Grid item> <Grid>
<Typography variant="subtitle2" color="primary"> <Typography variant="subtitle2" color="primary">
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}&nbsp;{LL.ENTITIES(deviceEntities.length)} {LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
<Table data={{ nodes: shown_data }} theme={entities_theme} layout={{ custom: true }}> <Table
{(tableList: any) => ( data={{ nodes: shown_data }}
theme={entities_theme}
layout={{ custom: true }}
>
{(tableList: DeviceEntity[]) => (
<> <>
<Header> <Header>
<HeaderRow> <HeaderRow>
@@ -473,13 +623,20 @@ const SettingsCustomization: FC = () => {
</Cell> </Cell>
<Cell> <Cell>
{formatName(de, false)}&nbsp;( {formatName(de, false)}&nbsp;(
<Link target="_blank" href={APIURL + selectedDeviceName + '/' + de.id}> <Link
target="_blank"
href={APIURL + selectedDeviceTypeNameURL + '/' + de.id}
>
{de.id} {de.id}
</Link> </Link>
) )
</Cell> </Cell>
<Cell>{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}</Cell> <Cell>
<Cell>{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)}</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> <Cell>{formatValue(de.v)}</Cell>
</Row> </Row>
))} ))}
@@ -492,14 +649,28 @@ const SettingsCustomization: FC = () => {
}; };
const renderResetDialog = () => ( const renderResetDialog = () => (
<Dialog sx={dialogStyle} open={confirmReset} onClose={() => setConfirmReset(false)}> <Dialog
sx={dialogStyle}
open={confirmReset}
onClose={() => setConfirmReset(false)}
>
<DialogTitle>{LL.RESET(1)}</DialogTitle> <DialogTitle>{LL.RESET(1)}</DialogTitle>
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent> <DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
<DialogActions> <DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={() => setConfirmReset(false)} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmReset(false)}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button startIcon={<SettingsBackupRestoreIcon />} variant="outlined" onClick={resetCustomization} color="error"> <Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={resetCustomization}
color="error"
>
{LL.RESET(0)} {LL.RESET(0)}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -508,19 +679,20 @@ const SettingsCustomization: FC = () => {
const renderContent = () => ( const renderContent = () => (
<> <>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.DEVICE_ENTITIES()}
</Typography>
{devices && renderDeviceList()} {devices && renderDeviceList()}
{deviceEntities && renderDeviceData()} {selectedDevice !== -1 && !rename && renderDeviceData()}
{restartNeeded && ( {restartNeeded ? (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}> <MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button startIcon={<PowerSettingsNewIcon />} variant="contained" color="error" onClick={restart}> <Button
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
onClick={doRestart}
>
{LL.RESTART()} {LL.RESTART()}
</Button> </Button>
</MessageBox> </MessageBox>
)} ) : (
{!restartNeeded && (
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1}> <Box flexGrow={1}>
{numChanges !== 0 && ( {numChanges !== 0 && (
@@ -529,7 +701,7 @@ const SettingsCustomization: FC = () => {
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => devices && readDeviceEntities(selectedDevice)} onClick={() => devices && sendDeviceEntities(selectedDevice)}
> >
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
@@ -544,7 +716,8 @@ const SettingsCustomization: FC = () => {
</ButtonRow> </ButtonRow>
)} )}
</Box> </Box>
<ButtonRow> {!rename && (
<ButtonRow mt={1}>
<Button <Button
startIcon={<SettingsBackupRestoreIcon />} startIcon={<SettingsBackupRestoreIcon />}
variant="outlined" variant="outlined"
@@ -554,6 +727,7 @@ const SettingsCustomization: FC = () => {
{LL.RESET(0)} {LL.RESET(0)}
</Button> </Button>
</ButtonRow> </ButtonRow>
)}
</Box> </Box>
)} )}
{renderResetDialog()} {renderResetDialog()}
@@ -561,11 +735,11 @@ const SettingsCustomization: FC = () => {
); );
return ( return (
<SectionContent title={LL.CUSTOMIZATIONS()} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : renderContent()} {restarting ? <RestartMonitor /> : renderContent()}
{selectedDeviceEntity && ( {selectedDeviceEntity && (
<SettingsCustomizationDialog <SettingsCustomizationsDialog
open={dialogOpen} open={dialogOpen}
onClose={onDialogClose} onClose={onDialogClose}
onSave={onDialogSave} onSave={onDialogSave}
@@ -576,4 +750,4 @@ const SettingsCustomization: FC = () => {
); );
}; };
export default SettingsCustomization; export default Customizations;

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
import { import {
Box, Box,
Button, Button,
@@ -9,29 +10,32 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid,
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useEffect, useState } from 'react'; 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 EntityMaskToggle from './EntityMaskToggle';
import { DeviceEntityMask } from './types'; import { DeviceEntityMask } from './types';
import type { DeviceEntity } from './types'; import type { DeviceEntity } from './types';
import { dialogStyle } from 'CustomTheme'; interface SettingsCustomizationsDialogProps {
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
type SettingsCustomizationDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSave: (di: DeviceEntity) => void; onSave: (di: DeviceEntity) => void;
selectedItem: DeviceEntity; selectedItem: DeviceEntity;
}; }
const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: SettingsCustomizationDialogProps) => { const CustomizationsDialog = ({
open,
onClose,
onSave,
selectedItem
}: SettingsCustomizationsDialogProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
@@ -39,7 +43,9 @@ const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: Se
const updateFormValue = updateValue(setEditItem); const updateFormValue = updateValue(setEditItem);
const isWriteableNumber = const isWriteableNumber =
typeof editItem.v === 'number' && editItem.w && !(editItem.m & DeviceEntityMask.DV_READONLY); typeof editItem.v === 'number' &&
editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -48,12 +54,19 @@ const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: Se
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const close = () => { const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose(); onClose();
}
}; };
const save = () => { const save = () => {
if (isWriteableNumber && editItem.mi && editItem.ma && editItem.mi > editItem?.ma) { if (
isWriteableNumber &&
editItem.mi &&
editItem.ma &&
editItem.mi > editItem?.ma
) {
setError(true); setError(true);
} else { } else {
onSave(editItem); onSave(editItem);
@@ -65,10 +78,10 @@ const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: Se
}; };
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={close}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle> <DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container direction="row"> <Grid container>
<Typography variant="body2" color="warning.main"> <Typography variant="body2" color="warning.main">
{LL.ID_OF(LL.ENTITY())}:&nbsp; {LL.ID_OF(LL.ENTITY())}:&nbsp;
</Typography> </Typography>
@@ -98,35 +111,34 @@ const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: Se
<Box mt={1} mb={2}> <Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box> </Box>
<Grid container spacing={1}> <Grid container spacing={2}>
<Grid item> <Grid>
<TextField <TextField
name="cn" name="cn"
label={LL.NEW_NAME_OF(LL.ENTITY())} label={LL.NEW_NAME_OF(LL.ENTITY())}
value={editItem.cn} value={editItem.cn}
autoFocus
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
onChange={updateFormValue} onChange={updateFormValue}
/> />
</Grid> </Grid>
{isWriteableNumber && ( {isWriteableNumber && (
<> <>
<Grid item> <Grid>
<TextField <TextField
name="mi" name="mi"
label={LL.MIN()} label={LL.MIN()}
value={editItem.mi} value={numberValue(editItem.mi)}
sx={{ width: '8ch' }} sx={{ width: '11ch' }}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
/> />
</Grid> </Grid>
<Grid item> <Grid>
<TextField <TextField
name="ma" name="ma"
label={LL.MAX()} label={LL.MAX()}
value={editItem.ma} value={numberValue(editItem.ma)}
sx={{ width: '8ch' }} sx={{ width: '11ch' }}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
/> />
@@ -141,10 +153,20 @@ const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: Se
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={close} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button startIcon={<DoneIcon />} variant="outlined" onClick={save} color="primary"> <Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{LL.UPDATE()} {LL.UPDATE()}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -152,4 +174,4 @@ const SettingsCustomizationDialog = ({ open, onClose, onSave, selectedItem }: Se
); );
}; };
export default SettingsCustomizationDialog; export default CustomizationsDialog;

View File

@@ -0,0 +1,366 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,769 @@
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,36 +1,35 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box,
Button, Button,
CircularProgress,
Dialog, Dialog,
DialogTitle,
DialogContent,
DialogActions, DialogActions,
DialogContent,
DialogTitle,
FormHelperText,
InputAdornment, InputAdornment,
MenuItem, MenuItem,
TextField, TextField,
FormHelperText, Typography
Grid,
Box,
Typography,
CircularProgress
} from '@mui/material'; } from '@mui/material';
import { useState, useEffect } from 'react'; 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 { DeviceValueUOM, DeviceValueUOM_s } from './types';
import type { DeviceValue } from './types'; import type { DeviceValue } from './types';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator'; interface DevicesDialogProps {
import { dialogStyle } from 'CustomTheme';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue, numberValue } from 'utils';
import { validate } from 'validators';
type DashboardDevicesDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSave: (as: DeviceValue) => void; onSave: (as: DeviceValue) => void;
@@ -38,9 +37,9 @@ type DashboardDevicesDialogProps = {
writeable: boolean; writeable: boolean;
validator: Schema; validator: Schema;
progress: boolean; progress: boolean;
}; }
const DashboardDevicesDialog = ({ const DevicesDialog = ({
open, open,
onClose, onClose,
onSave, onSave,
@@ -48,7 +47,7 @@ const DashboardDevicesDialog = ({
writeable, writeable,
validator, validator,
progress progress
}: DashboardDevicesDialogProps) => { }: DevicesDialogProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -71,12 +70,15 @@ const DashboardDevicesDialog = ({
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
onSave(editItem); onSave(editItem);
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
const setUom = (uom: number) => { const setUom = (uom?: DeviceValueUOM) => {
if (uom === undefined) {
return;
}
switch (uom) { switch (uom) {
case DeviceValueUOM.HOURS: case DeviceValueUOM.HOURS:
return LL.HOURS(); return LL.HOURS();
@@ -103,21 +105,24 @@ const DashboardDevicesDialog = ({
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={close}> <Dialog sx={dialogStyle} open={open} onClose={close}>
<DialogTitle> <DialogTitle>
{selectedItem.v === '' && selectedItem.c ? LL.RUN_COMMAND() : writeable ? LL.CHANGE_VALUE() : LL.VALUE(1)} {selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}> <Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{editItem.id.slice(2)}</Typography> <Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Box> </Box>
<Grid> <Grid container>
<Grid item> <Grid size={12}>
{editItem.l ? ( {editItem.l ? (
<TextField <TextField
name="v" name="v"
label={LL.VALUE(1)} label={LL.VALUE(0)}
value={editItem.v} value={editItem.v}
disabled={!writeable} disabled={!writeable}
autoFocus
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
select select
onChange={updateFormValue} onChange={updateFormValue}
@@ -132,34 +137,41 @@ const DashboardDevicesDialog = ({
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="v" name="v"
label={LL.VALUE(1)} label={LL.VALUE(0)}
value={numberValue(Math.round(editItem.v * 10) / 10)} value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus autoFocus
disabled={!writeable} disabled={!writeable}
type="number" type="number"
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
onChange={updateFormValue} onChange={updateFormValue}
inputProps={editItem.s ? { min: editItem.m, max: editItem.x, step: editItem.s } : {}} slotProps={{
InputProps={{ htmlInput: editItem.s
startAdornment: <InputAdornment position="start">{setUom(editItem.u)}</InputAdornment> ? { min: editItem.m, max: editItem.x, step: editItem.s }
: {},
input: {
startAdornment: (
<InputAdornment position="start">
{setUom(editItem.u)}
</InputAdornment>
)
}
}} }}
/> />
) : ( ) : (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="v" name="v"
label={LL.VALUE(1)} label={LL.VALUE(0)}
value={editItem.v} value={editItem.v}
disabled={!writeable} disabled={!writeable}
autoFocus
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
multiline={editItem.u ? false : true} multiline={!editItem.u}
onChange={updateFormValue} onChange={updateFormValue}
/> />
)} )}
</Grid> </Grid>
{writeable && ( {writeable && (
<Grid item> <Grid>
<FormHelperText>{showHelperText(editItem)}</FormHelperText> <FormHelperText>{showHelperText(editItem)}</FormHelperText>
</Grid> </Grid>
)} )}
@@ -176,10 +188,20 @@ const DashboardDevicesDialog = ({
position: 'relative' position: 'relative'
}} }}
> >
<Button startIcon={<CancelIcon />} variant="outlined" onClick={close} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={close}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button startIcon={<WarningIcon color="warning" />} variant="contained" onClick={save} color="info"> <Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"
>
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()} {selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()}
</Button> </Button>
{progress && ( {progress && (
@@ -204,4 +226,4 @@ const DashboardDevicesDialog = ({
); );
}; };
export default DashboardDevicesDialog; export default DevicesDialog;

View File

@@ -1,12 +1,13 @@
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types'; import { DeviceEntityMask } from './types';
import type { DeviceEntity } from './types'; import type { DeviceEntity } from './types';
type EntityMaskToggleProps = { interface EntityMaskToggleProps {
onUpdate: (de: DeviceEntity) => void; onUpdate: (de: DeviceEntity) => void;
de: DeviceEntity; de: DeviceEntity;
}; }
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
@@ -42,7 +43,7 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(de.m)} value={getMaskString(de.m)}
onChange={(event, mask) => { onChange={(event, mask: string[]) => {
de.m = getMaskNumber(mask); de.m = getMaskNumber(mask);
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) { if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE; de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
@@ -54,25 +55,46 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
}} }}
> >
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}> <ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}>
<OptionIcon type="favorite" isSet={(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE} /> <OptionIcon
type="favorite"
isSet={
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}> <ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}>
<OptionIcon type="readonly" isSet={(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY} /> <OptionIcon
type="readonly"
isSet={
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}> <ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}>
<OptionIcon <OptionIcon
type="api_mqtt_exclude" type="api_mqtt_exclude"
isSet={(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) === DeviceEntityMask.DV_API_MQTT_EXCLUDE} isSet={
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
DeviceEntityMask.DV_API_MQTT_EXCLUDE
}
/> />
</ToggleButton> </ToggleButton>
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}> <ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}>
<OptionIcon <OptionIcon
type="web_exclude" type="web_exclude"
isSet={(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) === DeviceEntityMask.DV_WEB_EXCLUDE} isSet={
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
DeviceEntityMask.DV_WEB_EXCLUDE
}
/> />
</ToggleButton> </ToggleButton>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon type="deleted" isSet={(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED} /> <OptionIcon
type="deleted"
isSet={
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );

View File

@@ -0,0 +1,191 @@
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

@@ -0,0 +1,269 @@
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

@@ -0,0 +1,106 @@
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

@@ -3,20 +3,26 @@ import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined'; import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined'; import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined';
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
import StarOutlineIcon from '@mui/icons-material/StarOutline'; import StarOutlineIcon from '@mui/icons-material/StarOutline';
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'; import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import type { SvgIconProps } from '@mui/material'; import type { SvgIconProps } from '@mui/material';
import type { FC } from 'react';
type OptionType = 'deleted' | 'readonly' | 'web_exclude' | 'api_mqtt_exclude' | 'favorite'; type OptionType =
| 'deleted'
| 'readonly'
| 'web_exclude'
| 'api_mqtt_exclude'
| 'favorite';
const OPTION_ICONS: { [type in OptionType]: [React.ComponentType<SvgIconProps>, React.ComponentType<SvgIconProps>] } = { const OPTION_ICONS: {
[type in OptionType]: [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
} = {
deleted: [DeleteForeverIcon, DeleteOutlineIcon], deleted: [DeleteForeverIcon, DeleteOutlineIcon],
readonly: [EditOffOutlinedIcon, EditOutlinedIcon], readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon], web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
@@ -24,12 +30,7 @@ const OPTION_ICONS: { [type in OptionType]: [React.ComponentType<SvgIconProps>,
favorite: [StarIcon, StarOutlineIcon] favorite: [StarIcon, StarOutlineIcon]
}; };
interface OptionIconProps { const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
type: OptionType;
isSet: boolean;
}
const OptionIcon: FC<OptionIconProps> = ({ type, isSet }) => {
const Icon = OPTION_ICONS[type][isSet ? 0 : 1]; const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return isSet ? ( return isSet ? (
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} /> <Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />

View File

@@ -1,28 +1,40 @@
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 AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle'; import CircleIcon from '@mui/icons-material/Circle';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Divider, Stack, Typography } from '@mui/material';
import { Box, Typography, Divider, Stack, Button } from '@mui/material'; import {
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'; Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme'; import { useTheme } from '@table-library/react-table-library/theme';
// eslint-disable-next-line import/named import { updateState, useRequest } from 'alova/client';
import { updateState, useRequest } from 'alova'; import {
import { useState, useEffect, useCallback } from 'react'; BlockNavigation,
import { useBlocker } from 'react-router-dom'; ButtonRow,
import { toast } from 'react-toastify'; FormLoader,
import SettingsSchedulerDialog from './SettingsSchedulerDialog'; SectionContent,
import * as EMSESP from './api'; useLayoutTitle
import { ScheduleFlag } from './types'; } from 'components';
import { schedulerItemValidation } from './validators';
import type { ScheduleItem } from './types';
import type { FC } from 'react';
import { ButtonRow, FormLoader, SectionContent, BlockNavigation } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const SettingsScheduler: FC = () => { 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 { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0); const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0); const blocker = useBlocker(numChanges !== 0);
@@ -31,16 +43,22 @@ const SettingsScheduler: FC = () => {
const [creating, setCreating] = useState<boolean>(false); const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.SCHEDULER());
const { const {
data: schedule, data: schedule,
send: fetchSchedule, send: fetchSchedule,
error error
} = useRequest(EMSESP.readSchedule, { } = useRequest(readSchedule, {
initialData: [], initialData: []
force: true
}); });
const { send: writeSchedule } = useRequest((data) => EMSESP.writeSchedule(data), { immediate: false }); const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
immediate: false
}
);
function hasScheduleChanged(si: ScheduleItem) { function hasScheduleChanged(si: ScheduleItem) {
return ( return (
@@ -56,7 +74,10 @@ const SettingsScheduler: FC = () => {
} }
useEffect(() => { useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }); const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => { const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day; const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`); return new Date(`2017-01-${dd}T00:00:00+00:00`);
@@ -66,7 +87,7 @@ const SettingsScheduler: FC = () => {
const schedule_theme = useTheme({ const schedule_theme = useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 36px 324px 50px 192px repeat(1, minmax(100px, 1fr)) 160px; --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -96,21 +117,16 @@ const SettingsScheduler: FC = () => {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
.td { .td {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
&:hover .td { &:hover .td {
border-top: 1px solid #177ac9; background-color: #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
} }
` `
}); });
const saveSchedule = async () => { const saveSchedule = async () => {
await writeSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
.filter((si) => !si.deleted) .filter((si) => !si.deleted)
.map((condensed_si) => ({ .map((condensed_si) => ({
@@ -126,8 +142,8 @@ const SettingsScheduler: FC = () => {
.then(() => { .then(() => {
toast.success(LL.SCHEDULE_UPDATED()); toast.success(LL.SCHEDULE_UPDATED());
}) })
.catch((err) => { .catch((error: Error) => {
toast.error(err.message); toast.error(error.message);
}) })
.finally(async () => { .finally(async () => {
await fetchSchedule(); await fetchSchedule();
@@ -139,6 +155,9 @@ const SettingsScheduler: FC = () => {
setCreating(false); setCreating(false);
setSelectedScheduleItem(si); setSelectedScheduleItem(si);
setDialogOpen(true); setDialogOpen(true);
if (si.o_name === undefined) {
si.o_name = si.name;
}
}, []); }, []);
const onDialogClose = () => { const onDialogClose = () => {
@@ -153,12 +172,18 @@ const SettingsScheduler: FC = () => {
const onDialogSave = (updatedItem: ScheduleItem) => { const onDialogSave = (updatedItem: ScheduleItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
updateState('schedule', (data) => {
const new_data = creating 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)); ...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); setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
return new_data; return new_data;
}); });
}; };
@@ -169,8 +194,8 @@ const SettingsScheduler: FC = () => {
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
active: false, active: false,
deleted: false, deleted: false,
flags: 0, flags: ScheduleFlag.SCHEDULE_DAY,
time: '12:00', time: '',
cmd: '', cmd: '',
value: '', value: '',
name: '' name: ''
@@ -186,29 +211,54 @@ const SettingsScheduler: FC = () => {
const dayBox = (si: ScheduleItem, flag: number) => ( const dayBox = (si: ScheduleItem, flag: number) => (
<> <>
<Box> <Box>
<Typography sx={{ fontSize: 11 }} color={(si.flags & flag) === flag ? 'primary' : 'grey'}> <Typography
{flag === ScheduleFlag.SCHEDULE_TIMER ? LL.TIMER(0) : dow[Math.log(flag) / Math.log(2)]} sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography> </Typography>
</Box> </Box>
<Divider orientation="vertical" flexItem /> <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 ( return (
<Table <Table
data={{ nodes: schedule.filter((si) => !si.deleted).sort((a, b) => a.time.localeCompare(b.time)) }} data={{
nodes: schedule
.filter((si) => !si.deleted)
.sort((a, b) => a.flags - b.flags)
}}
theme={schedule_theme} theme={schedule_theme}
layout={{ custom: true }} layout={{ custom: true }}
> >
{(tableList: any) => ( {(tableList: ScheduleItem[]) => (
<> <>
<Header> <Header>
<HeaderRow> <HeaderRow>
<HeaderCell /> <HeaderCell />
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell> <HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff>{LL.TIME(0)}</HeaderCell> <HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell> <HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(1)}</HeaderCell> <HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell> <HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow> </HeaderRow>
</Header> </Header>
@@ -217,14 +267,24 @@ const SettingsScheduler: FC = () => {
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}> <Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff> <Cell stiff>
{si.active ? ( {si.active ? (
<CircleIcon color="success" sx={{ fontSize: 16, verticalAlign: 'middle' }} /> <CircleIcon
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : ( ) : (
<CircleIcon color="error" sx={{ fontSize: 16, verticalAlign: 'middle' }} /> <CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)} )}
</Cell> </Cell>
<Cell stiff> <Cell stiff>
<Stack spacing={1} direction="row"> <Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
{si.flags > 127 ? (
scheduleType(si)
) : (
<>
{dayBox(si, ScheduleFlag.SCHEDULE_MON)} {dayBox(si, ScheduleFlag.SCHEDULE_MON)}
{dayBox(si, ScheduleFlag.SCHEDULE_TUE)} {dayBox(si, ScheduleFlag.SCHEDULE_TUE)}
{dayBox(si, ScheduleFlag.SCHEDULE_WED)} {dayBox(si, ScheduleFlag.SCHEDULE_WED)}
@@ -232,7 +292,8 @@ const SettingsScheduler: FC = () => {
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)} {dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)} {dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)} {dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
{dayBox(si, ScheduleFlag.SCHEDULE_TIMER)} </>
)}
</Stack> </Stack>
</Cell> </Cell>
<Cell>{si.time}</Cell> <Cell>{si.time}</Cell>
@@ -249,10 +310,10 @@ const SettingsScheduler: FC = () => {
}; };
return ( return (
<SectionContent title={LL.SCHEDULER()} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
<Box mb={2} color="warning.main"> <Box mb={2} color="warning.main">
<Typography variant="body2">{LL.SCHEDULER_HELP_1()}</Typography> <Typography variant="body1">{LL.SCHEDULER_HELP_1()}.</Typography>
</Box> </Box>
{renderSchedule()} {renderSchedule()}
@@ -268,11 +329,16 @@ const SettingsScheduler: FC = () => {
/> />
)} )}
<Box display="flex" flexWrap="wrap"> <Box mt={1} display="flex" flexWrap="wrap">
<Box flexGrow={1}> <Box flexGrow={1}>
{numChanges !== 0 && ( {numChanges !== 0 && (
<ButtonRow> <ButtonRow>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={onDialogCancel} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
@@ -288,7 +354,12 @@ const SettingsScheduler: FC = () => {
</Box> </Box>
<Box flexWrap="nowrap" whiteSpace="nowrap"> <Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow> <ButtonRow>
<Button startIcon={<AddIcon />} variant="outlined" color="secondary" onClick={addScheduleItem}> <Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addScheduleItem}
>
{LL.ADD(0)} {LL.ADD(0)}
</Button> </Button>
</ButtonRow> </ButtonRow>
@@ -298,4 +369,4 @@ const SettingsScheduler: FC = () => {
); );
}; };
export default SettingsScheduler; export default Scheduler;

View File

@@ -0,0 +1,401 @@
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,57 +1,96 @@
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined'; import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
import RefreshIcon from '@mui/icons-material/Refresh';
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined'; import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
import { Button, Typography, Box } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
import { useSort, SortToggleType } from '@table-library/react-table-library/sort';
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'; 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 { useTheme } from '@table-library/react-table-library/theme';
import { useRequest } from 'alova'; import type { State } from '@table-library/react-table-library/types/common';
import { useState, useContext, useEffect } from 'react'; import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'react-toastify';
import DashboardSensorsAnalogDialog from './DashboardSensorsAnalogDialog';
import DashboardSensorsTemperatureDialog from './DashboardSensorsTemperatureDialog';
import * as EMSESP from './api';
import { DeviceValueUOM, DeviceValueUOM_s, AnalogTypeNames, AnalogType } from './types';
import { temperatureSensorItemValidation, analogSensorItemValidation } from './validators';
import type { TemperatureSensor, AnalogSensor } from './types';
import type { FC } from 'react';
import { ButtonRow, SectionContent } from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
const DashboardSensors: FC = () => { 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 { LL } = useI18nContext();
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [selectedTemperatureSensor, setSelectedTemperatureSensor] = useState<TemperatureSensor>();
const [selectedTemperatureSensor, setSelectedTemperatureSensor] =
useState<TemperatureSensor>();
const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>(); const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>();
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false); const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false); const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
const [creating, setCreating] = useState<boolean>(false); const [creating, setCreating] = useState<boolean>(false);
const { data: sensorData, send: fetchSensorData } = useRequest(() => EMSESP.readSensorData(), { const { data: sensorData, send: fetchSensorData } = useRequest(
() => readSensorData(),
{
initialData: { initialData: {
ts: [], ts: [],
as: [], as: [],
analog_enabled: false, analog_enabled: false,
platform: 'ESP32' platform: 'ESP32'
} }
}); }
);
const { send: writeTemperatureSensor } = useRequest((data) => EMSESP.writeTemperatureSensor(data), { const { send: sendTemperatureSensor } = useRequest(
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
{
immediate: false immediate: false
}); }
);
const { send: writeAnalogSensor } = useRequest((data) => EMSESP.writeAnalogSensor(data), { const { send: sendAnalogSensor } = useRequest(
(data: WriteAnalogSensor) => writeAnalogSensor(data),
{
immediate: false immediate: false
}); }
);
const isAdmin = me.admin; useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
}, 3000);
const common_theme = useTheme({ const common_theme = useTheme({
BaseRow: ` BaseRow: `
@@ -77,19 +116,10 @@ const DashboardSensors: FC = () => {
cursor: pointer; cursor: pointer;
.td { .td {
padding: 8px; padding: 8px;
border-top: 1px solid #565656;
border-bottom: 1px solid #565656; border-bottom: 1px solid #565656;
} }
&.tr.tr-body.row-select.row-select-single-selected {
background-color: #3d4752;
font-weight: normal;
}
&:hover .td { &:hover .td {
border-top: 1px solid #177ac9; background-color: #177ac9;
border-bottom: 1px solid #177ac9;
}
&:nth-of-type(odd) .td {
background-color: #303030;
} }
`, `,
Cell: ` Cell: `
@@ -117,7 +147,57 @@ const DashboardSensors: FC = () => {
} }
]); ]);
const getSortIcon = (state: any, sortKey: any) => { 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) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -139,6 +219,7 @@ const DashboardSensors: FC = () => {
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g), 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)), NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
TYPE: (array) => array.sort((a, b) => a.t - b.t), TYPE: (array) => array.sort((a, b) => a.t - b.t),
VALUE: (array) => array.sort((a, b) => a.v - b.v) VALUE: (array) => array.sort((a, b) => a.v - b.v)
@@ -157,18 +238,14 @@ const DashboardSensors: FC = () => {
}, },
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
VALUE: (array) => array.sort((a, b) => a.t - b.t) VALUE: (array) => array.sort((a, b) => a.t - b.t)
} }
} }
); );
useEffect(() => { useLayoutTitle(LL.SENSORS());
const timer = setInterval(() => fetchSensorData(), 30000);
return () => {
clearInterval(timer);
};
});
const formatDurationMin = (duration_min: number) => { const formatDurationMin = (duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000); const days = Math.trunc((duration_min * 60000) / 86400000);
@@ -188,10 +265,13 @@ const DashboardSensors: FC = () => {
return formatted; return formatted;
}; };
function formatValue(value: any, uom: number) { function formatValue(value: unknown, uom: DeviceValueUOM) {
if (value === undefined) { if (value === undefined) {
return ''; return '';
} }
if (typeof value !== 'number') {
return value as string;
}
switch (uom) { switch (uom) {
case DeviceValueUOM.HOURS: case DeviceValueUOM.HOURS:
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
@@ -200,10 +280,7 @@ const DashboardSensors: FC = () => {
case DeviceValueUOM.SECONDS: case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value }); return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE: case DeviceValueUOM.NONE:
if (typeof value === 'number') {
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat().format(value);
}
return value;
case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT: case DeviceValueUOM.FAHRENHEIT:
@@ -220,7 +297,8 @@ const DashboardSensors: FC = () => {
} }
const updateTemperatureSensor = (ts: TemperatureSensor) => { const updateTemperatureSensor = (ts: TemperatureSensor) => {
if (isAdmin) { if (me.admin) {
ts.o_n = ts.n;
setSelectedTemperatureSensor(ts); setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true); setTemperatureDialogOpen(true);
} }
@@ -228,26 +306,28 @@ const DashboardSensors: FC = () => {
const onTemperatureDialogClose = () => { const onTemperatureDialogClose = () => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData();
}; };
const onTemperatureDialogSave = async (ts: TemperatureSensor) => { const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
await writeTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o }) await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1))); toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
}) })
.catch(() => { .catch(() => {
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
}) })
.finally(async () => { .finally(() => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
setSelectedTemperatureSensor(undefined); setSelectedTemperatureSensor(undefined);
await fetchSensorData(); void fetchSensorData();
}); });
}; };
const updateAnalogSensor = (as: AnalogSensor) => { const updateAnalogSensor = (as: AnalogSensor) => {
if (isAdmin) { if (me.admin) {
setCreating(false); setCreating(false);
as.o_n = as.n;
setSelectedAnalogSensor(as); setSelectedAnalogSensor(as);
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
} }
@@ -255,6 +335,7 @@ const DashboardSensors: FC = () => {
const onAnalogDialogClose = () => { const onAnalogDialogClose = () => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData();
}; };
const addAnalogSensor = () => { const addAnalogSensor = () => {
@@ -268,13 +349,14 @@ const DashboardSensors: FC = () => {
o: 0, o: 0,
t: 0, t: 0,
f: 1, f: 1,
d: false d: false,
o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}; };
const onAnalogDialogSave = async (as: AnalogSensor) => { const onAnalogDialogSave = async (as: AnalogSensor) => {
await writeAnalogSensor({ await sendAnalogSensor({
id: as.id, id: as.id,
gpio: as.g, gpio: as.g,
name: as.n, name: as.n,
@@ -290,57 +372,21 @@ const DashboardSensors: FC = () => {
.catch(() => { .catch(() => {
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
}) })
.finally(async () => { .finally(() => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
setSelectedAnalogSensor(undefined); setSelectedAnalogSensor(undefined);
await fetchSensorData(); void fetchSensorData();
}); });
}; };
const RenderTemperatureSensors = () => (
<Table data={{ nodes: sensorData.ts }} theme={temperature_theme} sort={temperature_sort} layout={{ custom: true }}>
{(tableList: any) => (
<>
<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 RenderAnalogSensors = () => ( const RenderAnalogSensors = () => (
<Table data={{ nodes: sensorData.as }} theme={analog_theme} sort={analog_sort} layout={{ custom: true }}> <Table
{(tableList: any) => ( data={{ nodes: sensorData.as }}
theme={analog_theme}
sort={analog_sort}
layout={{ custom: true }}
>
{(tableList: AnalogSensor[]) => (
<> <>
<Header> <Header>
<HeaderRow> <HeaderRow>
@@ -406,10 +452,8 @@ const DashboardSensors: FC = () => {
); );
return ( return (
<SectionContent title={LL.SENSOR_DATA()} titleGutter> <SectionContent>
{sensorData.ts.length > 0 && ( <Typography sx={{ pb: 1 }} variant="h6" color="secondary">
<>
<Typography sx={{ pt: 2, pb: 1 }} variant="h6" color="secondary">
{LL.TEMP_SENSORS()} {LL.TEMP_SENSORS()}
</Typography> </Typography>
<RenderTemperatureSensors /> <RenderTemperatureSensors />
@@ -419,14 +463,12 @@ const DashboardSensors: FC = () => {
onClose={onTemperatureDialogClose} onClose={onTemperatureDialogClose}
onSave={onTemperatureDialogSave} onSave={onTemperatureDialogSave}
selectedItem={selectedTemperatureSensor} selectedItem={selectedTemperatureSensor}
validator={temperatureSensorItemValidation()} validator={temperatureSensorItemValidation(
sensorData.ts,
selectedTemperatureSensor
)}
/> />
)} )}
</>
)}
{sensorData?.analog_enabled === true && (
<>
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary"> <Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary">
{LL.ANALOG_SENSORS()} {LL.ANALOG_SENSORS()}
</Typography> </Typography>
@@ -438,33 +480,28 @@ const DashboardSensors: FC = () => {
onSave={onAnalogDialogSave} onSave={onAnalogDialogSave}
creating={creating} creating={creating}
selectedItem={selectedAnalogSensor} selectedItem={selectedAnalogSensor}
validator={analogSensorItemValidation(sensorData.as, creating, sensorData.platform)} validator={analogSensorItemValidation(
sensorData.as,
selectedAnalogSensor,
creating,
sensorData.platform
)}
/> />
)} )}
</> {sensorData?.analog_enabled === true && me.admin && (
)} <Box mt={1} display="flex" flexWrap="wrap" justifyContent="flex-end">
<ButtonRow>
<Box mt={2} display="flex" flexWrap="wrap">
<Box flexGrow={1}>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={fetchSensorData}>
{LL.REFRESH()}
</Button>
</Box>
{sensorData?.analog_enabled === true && (
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
startIcon={<AddCircleOutlineOutlinedIcon />} startIcon={<AddCircleOutlineOutlinedIcon />}
onClick={addAnalogSensor} onClick={addAnalogSensor}
> >
{LL.ADD(0) + ' ' + LL.ANALOG_SENSOR(1)} {LL.ADD(0)}
</Button> </Button>
)}
</Box> </Box>
</ButtonRow> )}
</SectionContent> </SectionContent>
); );
}; };
export default DashboardSensors; export default Sensors;

View File

@@ -1,44 +1,43 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline'; import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Button,
Typography,
Box, Box,
Button,
Dialog, Dialog,
DialogTitle,
DialogContent,
DialogActions, DialogActions,
DialogContent,
DialogTitle,
InputAdornment, InputAdornment,
Grid,
MenuItem, MenuItem,
TextField TextField,
Typography
} from '@mui/material'; } from '@mui/material';
import { useState, useEffect } from 'react'; 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 { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
import type { AnalogSensor } from './types'; import type { AnalogSensor } from './types';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { dialogStyle } from 'CustomTheme';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; interface DashboardSensorsAnalogDialogProps {
import { numberValue, updateValue } from 'utils';
import { validate } from 'validators';
type DashboardSensorsAnalogDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSave: (as: AnalogSensor) => void; onSave: (as: AnalogSensor) => void;
creating: boolean; creating: boolean;
selectedItem: AnalogSensor; selectedItem: AnalogSensor;
validator: Schema; validator: Schema;
}; }
const DashboardSensorsAnalogDialog = ({ const SensorsAnalogDialog = ({
open, open,
onClose, onClose,
onSave, onSave,
@@ -58,8 +57,10 @@ const DashboardSensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const close = () => { const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose(); onClose();
}
}; };
const save = async () => { const save = async () => {
@@ -67,8 +68,8 @@ const DashboardSensorsAnalogDialog = ({
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
onSave(editItem); onSave(editItem);
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
@@ -78,17 +79,19 @@ const DashboardSensorsAnalogDialog = ({
}; };
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={close}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;{LL.ANALOG_SENSOR(0)} {creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={4}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="g" name="g"
label="GPIO" label="GPIO"
sx={{ width: '11ch' }}
value={numberValue(editItem.g)} value={numberValue(editItem.g)}
type="number" type="number"
variant="outlined" variant="outlined"
@@ -96,13 +99,13 @@ const DashboardSensorsAnalogDialog = ({
/> />
</Grid> </Grid>
{creating && ( {creating && (
<Grid item> <Grid>
<Box color="warning.main" mt={2}> <Box color="warning.main" mt={2}>
<Typography variant="body2">{LL.WARN_GPIO()}</Typography> <Typography variant="body2">{LL.WARN_GPIO()}</Typography>
</Box> </Box>
</Grid> </Grid>
)} )}
<Grid item xs={12}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="n" name="n"
@@ -113,20 +116,34 @@ const DashboardSensorsAnalogDialog = ({
onChange={updateFormValue} onChange={updateFormValue}
/> />
</Grid> </Grid>
<Grid item xs={8}> <Grid>
<TextField name="t" label={LL.TYPE(0)} value={editItem.t} fullWidth select onChange={updateFormValue}> <TextField
name="t"
label={LL.TYPE(0)}
value={editItem.t}
fullWidth
select
onChange={updateFormValue}
>
{AnalogTypeNames.map((val, i) => ( {AnalogTypeNames.map((val, i) => (
<MenuItem key={i} value={i}> <MenuItem key={val} value={i}>
{val} {val}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
</Grid> </Grid>
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && ( {editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid item xs={4}> <Grid>
<TextField name="u" label={LL.UNIT()} value={editItem.u} fullWidth select onChange={updateFormValue}> <TextField
name="u"
label={LL.UNIT()}
value={editItem.u}
sx={{ width: '15ch' }}
select
onChange={updateFormValue}
>
{DeviceValueUOM_s.map((val, i) => ( {DeviceValueUOM_s.map((val, i) => (
<MenuItem key={i} value={i}> <MenuItem key={val} value={i}>
{val} {val}
</MenuItem> </MenuItem>
))} ))}
@@ -134,72 +151,84 @@ const DashboardSensorsAnalogDialog = ({
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.ADC && ( {editItem.t === AnalogType.ADC && (
<Grid item xs={4}> <Grid>
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
fullWidth
type="number" type="number"
sx={{ width: '11ch' }}
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ min: '0', max: '3300', step: '1' }} slotProps={{
InputProps={{ input: {
startAdornment: <InputAdornment position="start">mV</InputAdornment> startAdornment: (
<InputAdornment position="start">mV</InputAdornment>
)
},
htmlInput: { min: '0', max: '3300', step: '1' }
}} }}
/> />
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.COUNTER && ( {editItem.t === AnalogType.COUNTER && (
<Grid item xs={4}> <Grid>
<TextField <TextField
name="o" name="o"
label={LL.STARTVALUE()} label={LL.STARTVALUE()}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
fullWidth
type="number" type="number"
sx={{ width: '11ch' }}
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ step: '0.001' }} slotProps={{
htmlInput: { step: '0.001' }
}}
/> />
</Grid> </Grid>
)} )}
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && ( {editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
<Grid item xs={4}> <Grid>
<TextField <TextField
name="f" name="f"
label={LL.FACTOR()} label={LL.FACTOR()}
value={numberValue(editItem.f)} value={numberValue(editItem.f)}
fullWidth sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ step: '0.001' }} slotProps={{
htmlInput: { step: '0.001' }
}}
/> />
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.DIGITAL_OUT && (editItem.g === 25 || editItem.g === 26) && ( {editItem.t === AnalogType.DIGITAL_OUT &&
<Grid item xs={4}> (editItem.g === 25 || editItem.g === 26) && (
<Grid>
<TextField <TextField
name="o" name="o"
label={LL.VALUE(1)} label={LL.VALUE(0)}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
fullWidth sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ min: '0', max: '255', step: '1' }} slotProps={{
htmlInput: { min: '0', max: '255', step: '1' }
}}
/> />
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26 && ( {editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26 && (
<> <>
<Grid item xs={4}> <Grid>
<TextField <TextField
name="o" name="o"
label={LL.VALUE(1)} label={LL.VALUE(0)}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
fullWidth
select select
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
@@ -208,12 +237,12 @@ const DashboardSensorsAnalogDialog = ({
<MenuItem value={1}>{LL.ON()}</MenuItem> <MenuItem value={1}>{LL.ON()}</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={4}> <Grid>
<TextField <TextField
name="f" name="f"
label={LL.POLARITY()} label={LL.POLARITY()}
value={editItem.f} value={editItem.f}
fullWidth sx={{ width: '15ch' }}
select select
onChange={updateFormValue} onChange={updateFormValue}
> >
@@ -221,12 +250,12 @@ const DashboardSensorsAnalogDialog = ({
<MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem> <MenuItem value={-1}>{LL.ACTIVELOW()}</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={4}> <Grid>
<TextField <TextField
name="u" name="u"
label={LL.STARTVALUE()} label={LL.STARTVALUE()}
sx={{ width: '15ch' }}
value={editItem.u} value={editItem.u}
fullWidth
select select
onChange={updateFormValue} onChange={updateFormValue}
> >
@@ -241,35 +270,45 @@ const DashboardSensorsAnalogDialog = ({
</Grid> </Grid>
</> </>
)} )}
{(editItem.t === AnalogType.PWM_0 || editItem.t === AnalogType.PWM_1 || editItem.t === AnalogType.PWM_2) && ( {(editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2) && (
<> <>
<Grid item xs={4}> <Grid>
<TextField <TextField
name="f" name="f"
label={LL.FREQ()} label={LL.FREQ()}
value={numberValue(editItem.f)} value={numberValue(editItem.f)}
fullWidth
type="number" type="number"
variant="outlined" variant="outlined"
sx={{ width: '11ch' }}
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ min: '1', max: '5000', step: '1' }} slotProps={{
InputProps={{ input: {
startAdornment: <InputAdornment position="start">Hz</InputAdornment> startAdornment: (
<InputAdornment position="start">Hz</InputAdornment>
)
},
htmlInput: { min: '1', max: '5000', step: '1' }
}} }}
/> />
</Grid> </Grid>
<Grid item xs={4}> <Grid>
<TextField <TextField
name="o" name="o"
label={LL.DUTY_CYCLE()} label={LL.DUTY_CYCLE()}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
fullWidth
type="number" type="number"
sx={{ width: '11ch' }}
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ min: '0', max: '100', step: '0.1' }} slotProps={{
InputProps={{ input: {
startAdornment: <InputAdornment position="start">%</InputAdornment> startAdornment: (
<InputAdornment position="start">%</InputAdornment>
)
},
htmlInput: { min: '0', max: '100', step: '0.1' }
}} }}
/> />
</Grid> </Grid>
@@ -280,15 +319,30 @@ const DashboardSensorsAnalogDialog = ({
<DialogActions> <DialogActions>
{!creating && ( {!creating && (
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}> <Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
<Button startIcon={<RemoveIcon />} variant="outlined" color="error" onClick={remove}> <Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()} {LL.REMOVE()}
</Button> </Button>
</Box> </Box>
)} )}
<Button startIcon={<CancelIcon />} variant="outlined" onClick={close} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button startIcon={<WarningIcon color="warning" />} variant="contained" onClick={save} color="info"> <Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()} {creating ? LL.ADD(0) : LL.UPDATE()}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -296,4 +350,4 @@ const DashboardSensorsAnalogDialog = ({
); );
}; };
export default DashboardSensorsAnalogDialog; export default SensorsAnalogDialog;

View File

@@ -1,46 +1,45 @@
import { useEffect, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Button,
Typography,
Box, Box,
Button,
Dialog, Dialog,
DialogTitle,
DialogContent,
DialogActions, DialogActions,
DialogContent,
DialogTitle,
InputAdornment, InputAdornment,
Grid, TextField,
TextField Typography
} from '@mui/material'; } from '@mui/material';
import { useState, useEffect } from 'react'; import Grid from '@mui/material/Grid2';
import type { TemperatureSensor } from './types'; import { dialogStyle } from 'CustomTheme';
import type Schema from 'async-validator'; import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator'; import type { ValidateFieldsError } from 'async-validator';
import { dialogStyle } from 'CustomTheme';
import { ValidatedTextField } from 'components'; import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils'; import { numberValue, updateValue } from 'utils';
import { validate } from 'validators'; import { validate } from 'validators';
type DashboardSensorsTemperatureDialogProps = { import type { TemperatureSensor } from './types';
interface SensorsTemperatureDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSave: (ts: TemperatureSensor) => void; onSave: (ts: TemperatureSensor) => void;
selectedItem: TemperatureSensor; selectedItem: TemperatureSensor;
validator: Schema; validator: Schema;
}; }
const DashboardSensorsTemperatureDialog = ({ const SensorsTemperatureDialog = ({
open, open,
onClose, onClose,
onSave, onSave,
selectedItem, selectedItem,
validator validator
}: DashboardSensorsTemperatureDialogProps) => { }: SensorsTemperatureDialogProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
@@ -53,8 +52,10 @@ const DashboardSensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const close = () => { const handleClose = (_event, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose(); onClose();
}
}; };
const save = async () => { const save = async () => {
@@ -62,13 +63,13 @@ const DashboardSensorsTemperatureDialog = ({
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
onSave(editItem); onSave(editItem);
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={close}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()} {LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle> </DialogTitle>
@@ -78,40 +79,53 @@ const DashboardSensorsTemperatureDialog = ({
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography> </Typography>
</Box> </Box>
<Grid container spacing={1}> <Grid container spacing={2}>
<Grid item> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="n" name="n"
label={LL.NAME(0)} label={LL.NAME(0)}
value={editItem.n} value={editItem.n}
autoFocus
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
onChange={updateFormValue} onChange={updateFormValue}
/> />
</Grid> </Grid>
<Grid item> <Grid>
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={numberValue(editItem.o)} value={numberValue(editItem.o)}
sx={{ width: '12ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
inputProps={{ min: '-5', max: '5', step: '0.1' }} slotProps={{
InputProps={{ input: {
startAdornment: <InputAdornment position="start">°C</InputAdornment> startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}} }}
/> />
</Grid> </Grid>
</Grid> </Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={close} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button startIcon={<WarningIcon color="warning" />} variant="contained" onClick={save} color="info"> <Button
startIcon={<WarningIcon color="warning" />}
variant="outlined"
onClick={save}
color="primary"
>
{LL.UPDATE()} {LL.UPDATE()}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -119,4 +133,4 @@ const DashboardSensorsTemperatureDialog = ({
); );
}; };
export default DashboardSensorsTemperatureDialog; export default SensorsTemperatureDialog;

View File

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

View File

@@ -8,10 +8,13 @@ export interface Settings {
syslog_host: string; syslog_host: string;
syslog_port: number; syslog_port: number;
boiler_heatingoff: boolean; boiler_heatingoff: boolean;
remote_timeout_en: boolean;
remote_timeout: number;
shower_timer: boolean; shower_timer: boolean;
shower_alert: boolean; shower_alert: boolean;
shower_alert_coldshot: number; shower_alert_coldshot: number;
shower_alert_trigger: number; shower_alert_trigger: number;
shower_min_duration: number;
rx_gpio: number; rx_gpio: number;
tx_gpio: number; tx_gpio: number;
telnet_enabled: boolean; telnet_enabled: boolean;
@@ -35,6 +38,11 @@ export interface Settings {
eth_phy_addr: number; eth_phy_addr: number;
eth_clock_mode: number; eth_clock_mode: number;
platform: string; platform: string;
modbus_enabled: boolean;
modbus_port: number;
modbus_max_clients: number;
modbus_timeout: number;
developer_mode: boolean;
} }
export enum busConnectionStatus { export enum busConnectionStatus {
@@ -50,13 +58,7 @@ export interface Stat {
q: number; // quality q: number; // quality
} }
export interface Status { export interface Activity {
status: busConnectionStatus;
tx_mode: number;
uptime: number;
num_devices: number;
num_sensors: number;
num_analogs: number;
stats: Stat[]; stats: Stat[];
} }
@@ -70,6 +72,7 @@ export interface Device {
p: number; // productid p: number; // productid
v: string; // version v: string; // version
e: number; // entities e: number; // entities
url?: string; // lowercase type name used in API URL
} }
export interface TemperatureSensor { export interface TemperatureSensor {
@@ -78,6 +81,7 @@ export interface TemperatureSensor {
t?: number; // temp, optional t?: number; // temp, optional
o: number; // offset o: number; // offset
u: number; // uom u: number; // uom
o_n?: string;
} }
export interface AnalogSensor { export interface AnalogSensor {
@@ -90,6 +94,7 @@ export interface AnalogSensor {
f: number; f: number;
t: number; t: number;
d: boolean; // deleted flag d: boolean; // deleted flag
o_n?: string;
} }
export interface WriteTemperatureSensor { export interface WriteTemperatureSensor {
@@ -110,23 +115,18 @@ export interface CoreData {
devices: Device[]; devices: Device[];
} }
export interface DeviceShort { export interface DashboardItem {
i: number; // id id: number; // unique index
d?: number; // deviceid t?: number; // type from DeviceType
p?: number; // productid n?: string; // name, optional
s: string; // shortname dv?: DeviceValue; // device value, optional
t?: number; // device type id nodes?: DashboardItem[]; // children nodes, optional
tn?: string; // device type internal name
}
export interface Devices {
devices: DeviceShort[];
} }
export interface DeviceValue { export interface DeviceValue {
id: string; // index, contains mask+name id: string; // index, contains mask+name
v: any; // value, Number or String v?: unknown; // value, Number, String or Boolean - can be undefined
u: number; // uom u?: number; // uom, optional
c?: string; // command, optional c?: string; // command, optional
l?: string[]; // list, optional l?: string[]; // list, optional
h?: string; // help text, optional h?: string; // help text, optional
@@ -134,16 +134,18 @@ export interface DeviceValue {
m?: number; // min, optional m?: number; // min, optional
x?: number; // max, optional x?: number; // max, optional
} }
export interface DeviceData { export interface DeviceData {
data: DeviceValue[]; nodes: DeviceValue[];
} }
export interface DeviceEntity { export interface DeviceEntity {
id: string; // shortname id: string; // shortname
v?: any; // value, in any format, optional v?: unknown; // value, in any format, optional
n?: string; // fullname, optional n?: string; // fullname, optional
cn?: string; // custom fullname, optional cn?: string; // custom fullname, optional
m: number; // mask t?: string; // tag for name
m: DeviceEntityMask; // mask
w: boolean; // writeable w: boolean; // writeable
mi?: number; // min value mi?: number; // min value
ma?: number; // max value ma?: number; // max value
@@ -178,7 +180,8 @@ export enum DeviceValueUOM {
KMIN, KMIN,
K, K,
VOLTS, VOLTS,
MBAR MBAR,
LH
} }
export const DeviceValueUOM_s = [ export const DeviceValueUOM_s = [
@@ -206,21 +209,22 @@ export const DeviceValueUOM_s = [
'K*min', 'K*min',
'K', 'K',
'V', 'V',
'mbar' 'mbar',
'l/h'
]; ];
export enum AnalogType { export enum AnalogType {
REMOVED = -1, REMOVED = -1,
NOTUSED = 0, NOTUSED = 0,
DIGITAL_IN, DIGITAL_IN = 1,
COUNTER, COUNTER = 2,
ADC, ADC = 3,
TIMER, TIMER = 4,
RATE, RATE = 5,
DIGITAL_OUT, DIGITAL_OUT = 6,
PWM_0, PWM_0 = 7,
PWM_1, PWM_1 = 8,
PWM_2 PWM_2 = 9
} }
export const AnalogTypeNames = [ export const AnalogTypeNames = [
@@ -236,9 +240,7 @@ export const AnalogTypeNames = [
'PWM 2' 'PWM 2'
]; ];
type BoardProfiles = { type BoardProfiles = Record<string, string>;
[name: string]: string;
};
export const BOARD_PROFILES: BoardProfiles = { export const BOARD_PROFILES: BoardProfiles = {
S32: 'BBQKees Gateway S32', S32: 'BBQKees Gateway S32',
@@ -270,9 +272,16 @@ export interface BoardProfile {
export interface APIcall { export interface APIcall {
device: string; device: string;
entity: string; cmd: string;
id: any; id: number;
data?: string; // optional
} }
export interface Action {
action: string;
param?: string; // optional
}
export interface WriteAnalogSensor { export interface WriteAnalogSensor {
id: number; id: number;
gpio: number; gpio: number;
@@ -296,12 +305,12 @@ export enum DeviceEntityMask {
export interface ScheduleItem { export interface ScheduleItem {
id: number; // unique index id: number; // unique index
active: boolean; active: boolean;
deleted?: boolean; // optional deleted?: boolean;
flags: number; flags: number;
time: string; time: string; // also used for Condition and On Change
cmd: string; cmd: string;
value: string; value: string;
name: string; // optional name: string; // can be empty
o_id?: number; o_id?: number;
o_active?: boolean; o_active?: boolean;
o_deleted?: boolean; o_deleted?: boolean;
@@ -312,6 +321,28 @@ export interface ScheduleItem {
o_name?: 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 { export enum ScheduleFlag {
SCHEDULE_SUN = 1, SCHEDULE_SUN = 1,
SCHEDULE_MON = 2, SCHEDULE_MON = 2,
@@ -320,7 +351,12 @@ export enum ScheduleFlag {
SCHEDULE_THU = 16, SCHEDULE_THU = 16,
SCHEDULE_FRI = 32, SCHEDULE_FRI = 32,
SCHEDULE_SAT = 64, SCHEDULE_SAT = 64,
SCHEDULE_TIMER = 128 // 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 { export interface EntityItem {
@@ -333,7 +369,7 @@ export interface EntityItem {
factor: number; factor: number;
uom: number; uom: number;
value_type: number; value_type: number;
value?: any; value?: unknown;
writeable: boolean; writeable: boolean;
deleted?: boolean; deleted?: boolean;
o_id?: number; o_id?: number;
@@ -347,7 +383,7 @@ export interface EntityItem {
o_value_type?: number; o_value_type?: number;
o_deleted?: boolean; o_deleted?: boolean;
o_writeable?: boolean; o_writeable?: boolean;
o_value?: any; o_value?: unknown;
} }
export interface Entities { export interface Entities {
@@ -357,9 +393,10 @@ export interface Entities {
// matches emsdevice.h DeviceType // matches emsdevice.h DeviceType
export const enum DeviceType { export const enum DeviceType {
SYSTEM = 0, SYSTEM = 0,
TEMPERATURESENSOR, TEMPERATURESENSOR = 1,
ANALOGSENSOR, ANALOGSENSOR = 2,
SCHEDULER, SCHEDULER = 3,
CUSTOM = 4,
BOILER, BOILER,
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,
@@ -373,33 +410,36 @@ export const enum DeviceType {
EXTENSION, EXTENSION,
GENERIC, GENERIC,
HEATSOURCE, HEATSOURCE,
CUSTOM, VENTILATION,
WATER,
POOL,
UNKNOWN UNKNOWN
} }
// matches emsdevicevalue.h // matches emsdevicevalue.h
export const enum DeviceValueType { export const enum DeviceValueType {
BOOL, BOOL,
INT, INT8,
UINT, UINT8,
SHORT, INT16,
USHORT, UINT16,
ULONG, UINT24,
TIME, // same as ULONG (32 bits) TIME, // same as UINT24
UINT32,
ENUM, ENUM,
STRING, STRING, // RAW
CMD CMD
} }
export const DeviceValueTypeNames = [ export const DeviceValueTypeNames = [
//
'BOOL', 'BOOL',
'INT', 'INT8',
'UINT', 'UINT8',
'SHORT', 'INT16',
'USHORT', 'UINT16',
'ULONG', 'UINT24',
'TIME', 'TIME',
'UINT32',
'ENUM', 'ENUM',
'RAW', 'RAW',
'CMD' 'CMD'

View File

@@ -0,0 +1,504 @@
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,33 +1,33 @@
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem } from '@mui/material'; import { Button, Checkbox, MenuItem } from '@mui/material';
import { range } from 'lodash-es';
import { useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import type { APSettings } from 'types';
import * as APApi from 'api/ap'; import * as APApi from 'api/ap';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation,
ButtonRow, ButtonRow,
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
BlockNavigation useLayoutTitle
} from 'components'; } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { APSettingsType } from 'types';
import { APProvisionMode } from 'types'; import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { createAPSettingsValidator, validate } from 'validators'; import { createAPSettingsValidator, validate } from 'validators';
export const isAPEnabled = ({ provision_mode }: APSettings) => export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
const APSettingsForm: FC = () => { const APSettings = () => {
const { const {
loadData, loadData,
saving, saving,
@@ -39,16 +39,23 @@ const APSettingsForm: FC = () => {
blocker, blocker,
saveData, saveData,
errorMessage errorMessage
} = useRest<APSettings>({ } = useRest<APSettingsType>({
read: APApi.readAPSettings, read: APApi.readAPSettings,
update: APApi.updateAPSettings update: APApi.updateAPSettings
}); });
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF(LL.ACCESS_POINT(0)));
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue); const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -60,11 +67,16 @@ const APSettingsForm: FC = () => {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data); await validate(createAPSettingsValidator(data), data);
await saveData(); await saveData();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
// no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/
function range(a: number, b: number): number[] {
return a < b ? [a, ...range(a + 1, b)] : [b];
}
return ( return (
<> <>
<ValidatedTextField <ValidatedTextField
@@ -78,9 +90,15 @@ const APSettingsForm: FC = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>{LL.AP_PROVIDE_TEXT_1()}</MenuItem> <MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>{LL.AP_PROVIDE_TEXT_2()}</MenuItem> {LL.AP_PROVIDE_TEXT_1()}
<MenuItem value={APProvisionMode.AP_NEVER}>{LL.AP_PROVIDE_TEXT_3()}</MenuItem> </MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
{LL.AP_PROVIDE_TEXT_2()}
</MenuItem>
<MenuItem value={APProvisionMode.AP_NEVER}>
{LL.AP_PROVIDE_TEXT_3()}
</MenuItem>
</ValidatedTextField> </ValidatedTextField>
{isAPEnabled(data) && ( {isAPEnabled(data) && (
<> <>
@@ -123,7 +141,13 @@ const APSettingsForm: FC = () => {
))} ))}
</ValidatedTextField> </ValidatedTextField>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="ssid_hidden" checked={data.ssid_hidden} onChange={updateFormValue} />} control={
<Checkbox
name="ssid_hidden"
checked={data.ssid_hidden}
onChange={updateFormValue}
/>
}
label={LL.AP_HIDE_SSID()} label={LL.AP_HIDE_SSID()}
/> />
<ValidatedTextField <ValidatedTextField
@@ -182,7 +206,7 @@ const APSettingsForm: FC = () => {
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="secondary"
type="submit" type="submit"
onClick={loadData} onClick={loadData}
> >
@@ -205,11 +229,11 @@ const APSettingsForm: FC = () => {
}; };
return ( return (
<SectionContent title={LL.SETTINGS_OF(LL.ACCESS_POINT(1))} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );
}; };
export default APSettingsForm; export default APSettings;

View File

@@ -0,0 +1,131 @@
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,27 +1,36 @@
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment, TextField } from '@mui/material'; import {
import { useState } from 'react'; Button,
import type { ValidateFieldsError } from 'async-validator'; Checkbox,
import type { FC } from 'react'; InputAdornment,
MenuItem,
TextField,
Typography
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import type { MqttSettings } from 'types';
import * as MqttApi from 'api/mqtt'; import * as MqttApi from 'api/mqtt';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation,
ButtonRow, ButtonRow,
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
BlockNavigation useLayoutTitle
} from 'components'; } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils'; import { numberValue, updateValueDirty, useRest } from 'utils';
import { createMqttSettingsValidator, validate } from 'validators'; import { createMqttSettingsValidator, validate } from 'validators';
const MqttSettingsForm: FC = () => { const MqttSettings = () => {
const { const {
loadData, loadData,
saving, saving,
@@ -33,16 +42,26 @@ const MqttSettingsForm: FC = () => {
blocker, blocker,
saveData, saveData,
errorMessage errorMessage
} = useRest<MqttSettings>({ } = useRest<MqttSettingsType>({
read: MqttApi.readMqttSettings, read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings update: MqttApi.updateMqttSettings
}); });
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF('MQTT'));
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue); const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const SecondsInputProps = {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -54,24 +73,29 @@ const MqttSettingsForm: FC = () => {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data); await validate(createMqttSettingsValidator(data), data);
await saveData(); await saveData();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
return ( return (
<> <>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />} control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_MQTT()} label={LL.ENABLE_MQTT()}
/> />
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start"> <Grid container spacing={2} rowSpacing={0}>
<Grid item xs={12} sm={6}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
fullWidth
multiline multiline
variant="outlined" variant="outlined"
value={data.host} value={data.host}
@@ -79,12 +103,11 @@ const MqttSettingsForm: FC = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="port" name="port"
label="Port" label="Port"
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.port)} value={numberValue(data.port)}
type="number" type="number"
@@ -92,60 +115,55 @@ const MqttSettingsForm: FC = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
fullWidth
variant="outlined" variant="outlined"
value={data.base} value={data.base}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<TextField <TextField
name="client_id" name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'} label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
fullWidth
variant="outlined" variant="outlined"
value={data.client_id} value={data.client_id}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<TextField <TextField
name="username" name="username"
label={LL.USERNAME(0)} label={LL.USERNAME(0)}
fullWidth
variant="outlined" variant="outlined"
value={data.username} value={data.username}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<ValidatedPasswordField <ValidatedPasswordField
name="password" name="password"
label={LL.PASSWORD()} label={LL.PASSWORD()}
fullWidth
variant="outlined" variant="outlined"
value={data.password} value={data.password}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
InputProps={{ slotProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> input: SecondsInputProps
}} }}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.keep_alive)} value={numberValue(data.keep_alive)}
type="number" type="number"
@@ -153,12 +171,11 @@ const MqttSettingsForm: FC = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid>
<TextField <TextField
name="mqtt_qos" name="mqtt_qos"
label="QoS" label="QoS"
value={data.mqtt_qos} value={data.mqtt_qos}
fullWidth
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
@@ -172,7 +189,13 @@ const MqttSettingsForm: FC = () => {
</Grid> </Grid>
{data.enableTLS !== undefined && ( {data.enableTLS !== undefined && (
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="enableTLS" checked={data.enableTLS} onChange={updateFormValue} />} control={
<Checkbox
name="enableTLS"
checked={data.enableTLS}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_TLS()} label={LL.ENABLE_TLS()}
/> />
)} )}
@@ -180,23 +203,32 @@ const MqttSettingsForm: FC = () => {
<ValidatedPasswordField <ValidatedPasswordField
name="rootCA" name="rootCA"
label={LL.CERT()} label={LL.CERT()}
fullWidth
variant="outlined" variant="outlined"
value={data.rootCA} value={data.rootCA}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
)} )}
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="clean_session" checked={data.clean_session} onChange={updateFormValue} />} control={
<Checkbox
name="clean_session"
checked={data.clean_session}
onChange={updateFormValue}
/>
}
label={LL.MQTT_CLEAN_SESSION()} label={LL.MQTT_CLEAN_SESSION()}
/> />
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="mqtt_retain" checked={data.mqtt_retain} onChange={updateFormValue} />} control={
<Checkbox
name="mqtt_retain"
checked={data.mqtt_retain}
onChange={updateFormValue}
/>
}
label={LL.MQTT_RETAIN_FLAG()} label={LL.MQTT_RETAIN_FLAG()}
/> />
<Typography sx={{ pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.FORMATTING()} {LL.FORMATTING()}
</Typography> </Typography>
@@ -204,7 +236,6 @@ const MqttSettingsForm: FC = () => {
name="nested_format" name="nested_format"
label={LL.MQTT_FORMAT()} label={LL.MQTT_FORMAT()}
value={data.nested_format} value={data.nested_format}
fullWidth
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
@@ -214,29 +245,38 @@ const MqttSettingsForm: FC = () => {
<MenuItem value={2}>{LL.MQTT_NEST_2()}</MenuItem> <MenuItem value={2}>{LL.MQTT_NEST_2()}</MenuItem>
</TextField> </TextField>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="send_response" checked={data.send_response} onChange={updateFormValue} />} control={
<Checkbox
name="send_response"
checked={data.send_response}
onChange={updateFormValue}
/>
}
label={LL.MQTT_RESPONSE()} label={LL.MQTT_RESPONSE()}
/> />
{!data.ha_enabled && ( {!data.ha_enabled && (
<Grid <Grid container spacing={2} rowSpacing={0}>
container <Grid>
rowSpacing={-1}
spacing={1}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="publish_single" checked={data.publish_single} onChange={updateFormValue} />} control={
<Checkbox
name="publish_single"
checked={data.publish_single}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_1()} label={LL.MQTT_PUBLISH_TEXT_1()}
/> />
</Grid> </Grid>
{data.publish_single && ( {data.publish_single && (
<Grid item> <Grid>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
<Checkbox name="publish_single2cmd" checked={data.publish_single2cmd} onChange={updateFormValue} /> <Checkbox
name="publish_single2cmd"
checked={data.publish_single2cmd}
onChange={updateFormValue}
/>
} }
label={LL.MQTT_PUBLISH_TEXT_2()} label={LL.MQTT_PUBLISH_TEXT_2()}
/> />
@@ -245,28 +285,26 @@ const MqttSettingsForm: FC = () => {
</Grid> </Grid>
)} )}
{!data.publish_single && ( {!data.publish_single && (
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start"> <Grid container spacing={2} rowSpacing={0}>
<Grid item> <Grid>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="ha_enabled" checked={data.ha_enabled} onChange={updateFormValue} />} control={
<Checkbox
name="ha_enabled"
checked={data.ha_enabled}
onChange={updateFormValue}
/>
}
label={LL.MQTT_PUBLISH_TEXT_3()} label={LL.MQTT_PUBLISH_TEXT_3()}
/> />
</Grid> </Grid>
{data.ha_enabled && ( {data.ha_enabled && (
<Grid <Grid container spacing={2} rowSpacing={0}>
container <Grid>
sx={{ pl: 1 }}
spacing={1}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item xs={12} sm={6} md={4}>
<TextField <TextField
name="discovery_type" name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()} label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type} value={data.discovery_type}
fullWidth
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
@@ -277,29 +315,33 @@ const MqttSettingsForm: FC = () => {
<MenuItem value={2}>Domoticz (latest)</MenuItem> <MenuItem value={2}>Domoticz (latest)</MenuItem>
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="discovery_prefix" name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()} label={LL.MQTT_PUBLISH_TEXT_4()}
fullWidth
variant="outlined" variant="outlined"
value={data.discovery_prefix} value={data.discovery_prefix}
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="entity_format" name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()} label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format} value={data.entity_format}
fullWidth
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
select select
> >
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem> <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={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem> <MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</TextField> </TextField>
@@ -311,16 +353,15 @@ const MqttSettingsForm: FC = () => {
<Typography sx={{ pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto) {LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography> </Typography>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start"> <Grid container spacing={2} rowSpacing={0}>
<Grid item xs={12} sm={6} md={4}> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors} fieldErrors={fieldErrors}
name="publish_time_heartbeat" name="publish_time_heartbeat"
label="Heartbeat" label="Heartbeat"
InputProps={{ slotProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> input: SecondsInputProps
}} }}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_heartbeat)} value={numberValue(data.publish_time_heartbeat)}
type="number" type="number"
@@ -328,105 +369,112 @@ const MqttSettingsForm: FC = () => {
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="publish_time_boiler" name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()} label={LL.MQTT_INT_BOILER()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_boiler)} value={numberValue(data.publish_time_boiler)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
slotProps={{
input: SecondsInputProps
}}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="publish_time_thermostat" name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()} label={LL.MQTT_INT_THERMOSTATS()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_thermostat)} value={numberValue(data.publish_time_thermostat)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
slotProps={{
input: SecondsInputProps
}}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="publish_time_solar" name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()} label={LL.MQTT_INT_SOLAR()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_solar)} value={numberValue(data.publish_time_solar)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
slotProps={{
input: SecondsInputProps
}}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="publish_time_mixer" name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()} label={LL.MQTT_INT_MIXER()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_mixer)} value={numberValue(data.publish_time_mixer)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
slotProps={{
input: SecondsInputProps
}}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <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 <TextField
name="publish_time_sensor" name="publish_time_sensor"
label={LL.TEMP_SENSORS()} label={LL.TEMP_SENSORS()}
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_sensor)} value={numberValue(data.publish_time_sensor)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
slotProps={{
input: SecondsInputProps
}}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={4}> <Grid>
<TextField <TextField
name="publish_time_other" name="publish_time_other"
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
label={LL.DEFAULT(0)} label={LL.DEFAULT(0)}
fullWidth
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_other)} value={numberValue(data.publish_time_other)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
slotProps={{
input: SecondsInputProps
}}
/> />
</Grid> </Grid>
</Grid> </Grid>
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="secondary"
type="submit" type="submit"
onClick={loadData} onClick={loadData}
> >
@@ -449,11 +497,11 @@ const MqttSettingsForm: FC = () => {
}; };
return ( return (
<SectionContent title={LL.SETTINGS_OF('MQTT')} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );
}; };
export default MqttSettingsForm; export default MqttSettings;

View File

@@ -1,29 +1,32 @@
import { useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem } from '@mui/material'; import { Button, Checkbox, MenuItem } from '@mui/material';
// eslint-disable-next-line import/named
import { updateState } from 'alova';
import { useState } from 'react';
import { selectedTimeZone, timeZoneSelectItems, TIME_ZONES } from './TZ';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import type { NTPSettings } from 'types';
import * as NTPApi from 'api/ntp'; import * as NTPApi from 'api/ntp';
import { readNTPSettings } from 'api/ntp';
import { updateState } from 'alova/client';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation,
ButtonRow, ButtonRow,
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedTextField, ValidatedTextField,
BlockNavigation useLayoutTitle
} from 'components'; } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { validate } from 'validators'; import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
const NTPSettingsForm: FC = () => { import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ';
const NTPSettings = () => {
const { const {
loadData, loadData,
saving, saving,
@@ -35,14 +38,20 @@ const NTPSettingsForm: FC = () => {
blocker, blocker,
saveData, saveData,
errorMessage errorMessage
} = useRest<NTPSettings>({ } = useRest<NTPSettingsType>({
read: NTPApi.readNTPSettings, read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings update: NTPApi.updateNTPSettings
}); });
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS_OF('NTP'));
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue); const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -56,15 +65,14 @@ const NTPSettingsForm: FC = () => {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data); await validate(NTP_SETTINGS_VALIDATOR, data);
await saveData(); await saveData();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => { const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
updateFormValue(event); updateFormValue(event);
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
updateState('ntpSettings', (settings) => ({
...settings, ...settings,
tz_label: event.target.value, tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value] tz_format: TIME_ZONES[event.target.value]
@@ -74,7 +82,13 @@ const NTPSettingsForm: FC = () => {
return ( return (
<> <>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />} control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label={LL.ENABLE_NTP()} label={LL.ENABLE_NTP()}
/> />
<ValidatedTextField <ValidatedTextField
@@ -107,7 +121,7 @@ const NTPSettingsForm: FC = () => {
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="secondary"
type="submit" type="submit"
onClick={loadData} onClick={loadData}
> >
@@ -130,11 +144,11 @@ const NTPSettingsForm: FC = () => {
}; };
return ( return (
<SectionContent title={LL.SETTINGS_OF('NTP')} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );
}; };
export default NTPSettingsForm; export default NTPSettings;

View File

@@ -0,0 +1,176 @@
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,8 +1,6 @@
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
type TimeZones = { type TimeZones = Record<string, string>;
[name: string]: string;
};
export const TIME_ZONES: TimeZones = { export const TIME_ZONES: TimeZones = {
'Africa/Abidjan': 'GMT0', 'Africa/Abidjan': 'GMT0',

View File

@@ -0,0 +1,277 @@
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

@@ -0,0 +1,57 @@
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,3 +1,6 @@
import { useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
@@ -12,43 +15,39 @@ import {
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemSecondaryAction,
ListItemText, ListItemText,
Typography, MenuItem,
TextField, TextField,
MenuItem Typography
} from '@mui/material'; } from '@mui/material';
// eslint-disable-next-line import/named
import { updateState, useRequest } from 'alova';
import { useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import RestartMonitor from '../system/RestartMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import type { NetworkSettings } from 'types';
import * as NetworkApi from 'api/network'; import * as NetworkApi from 'api/network';
import * as SystemApi from 'api/system'; import { API } from 'api/app';
import { updateState, useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import type { ValidateFieldsError } from 'async-validator';
import { import {
BlockFormControlLabel, BlockFormControlLabel,
BlockNavigation,
ButtonRow, ButtonRow,
FormLoader, FormLoader,
MessageBox,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField, ValidatedTextField
MessageBox,
BlockNavigation
} from 'components'; } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { validate } from 'validators'; import { validate } from 'validators';
import { createNetworkSettingsValidator } from 'validators/network'; import { createNetworkSettingsValidator } from 'validators/network';
const WiFiSettingsForm: FC = () => { import RestartMonitor from '../../status/RestartMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
const NetworkSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext); const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
@@ -68,38 +67,45 @@ const WiFiSettingsForm: FC = () => {
saveData, saveData,
errorMessage, errorMessage,
restartNeeded restartNeeded
} = useRest<NetworkSettings>({ } = useRest<NetworkSettingsType>({
read: NetworkApi.readNetworkSettings, read: NetworkApi.readNetworkSettings,
update: NetworkApi.updateNetworkSettings update: NetworkApi.updateNetworkSettings
}); });
const { send: restartCommand } = useRequest(SystemApi.restart(), { const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false immediate: false
}); });
useEffect(() => { useEffect(() => {
if (!initialized && data) { if (!initialized && data) {
if (selectedNetwork) { if (selectedNetwork) {
updateState('networkSettings', (current_data) => ({ void updateState(
NetworkApi.readNetworkSettings(),
(current_data: NetworkSettingsType) => ({
ssid: selectedNetwork.ssid, ssid: selectedNetwork.ssid,
bssid: selectedNetwork.bssid, bssid: selectedNetwork.bssid,
password: current_data ? current_data.password : '', password: current_data ? current_data.password : '',
hostname: current_data?.hostname, hostname: current_data?.hostname,
static_ip_config: false, static_ip_config: false,
enableIPv6: false,
bandwidth20: false, bandwidth20: false,
tx_power: 0, tx_power: 0,
nosleep: false, nosleep: false,
enableMDNS: true, enableMDNS: true,
enableCORS: false, enableCORS: false,
CORSOrigin: '*' CORSOrigin: '*'
})); })
);
} }
setInitialized(true); setInitialized(true);
} }
}, [initialized, setInitialized, data, selectedNetwork]); }, [initialized, setInitialized, data, selectedNetwork]);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue); const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -115,8 +121,8 @@ const WiFiSettingsForm: FC = () => {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data); await validate(createNetworkSettingsValidator(data), data);
await saveData(); await saveData();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
deselectNetwork(); deselectNetwork();
}; };
@@ -126,23 +132,27 @@ const WiFiSettingsForm: FC = () => {
await loadData(); await loadData();
}; };
const restart = async () => { const doRestart = async () => {
await restartCommand().catch((error) => {
toast.error(error.message);
});
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}; };
return ( return (
<> <>
<Typography sx={{ pt: 2 }} variant="h6" color="primary"> <Typography variant="h6" color="primary">
WiFi WiFi
</Typography> </Typography>
{selectedNetwork ? ( {selectedNetwork ? (
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar>{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />}</Avatar> <Avatar>
{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />}
</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={selectedNetwork.ssid} primary={selectedNetwork.ssid}
@@ -155,11 +165,9 @@ const WiFiSettingsForm: FC = () => {
selectedNetwork.bssid selectedNetwork.bssid
} }
/> />
<ListItemSecondaryAction>
<IconButton onClick={setCancel}> <IconButton onClick={setCancel}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</ListItemSecondaryAction>
</ListItem> </ListItem>
</List> </List>
) : ( ) : (
@@ -220,11 +228,23 @@ const WiFiSettingsForm: FC = () => {
<MenuItem value={8}>2 dBm</MenuItem> <MenuItem value={8}>2 dBm</MenuItem>
</TextField> </TextField>
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="nosleep" checked={data.nosleep} onChange={updateFormValue} />} control={
<Checkbox
name="nosleep"
checked={data.nosleep}
onChange={updateFormValue}
/>
}
label={LL.NETWORK_DISABLE_SLEEP()} label={LL.NETWORK_DISABLE_SLEEP()}
/> />
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="bandwidth20" checked={data.bandwidth20} onChange={updateFormValue} />} control={
<Checkbox
name="bandwidth20"
checked={data.bandwidth20}
onChange={updateFormValue}
/>
}
label={LL.NETWORK_LOW_BAND()} label={LL.NETWORK_LOW_BAND()}
/> />
<Typography sx={{ pt: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2 }} variant="h6" color="primary">
@@ -241,11 +261,23 @@ const WiFiSettingsForm: FC = () => {
margin="normal" margin="normal"
/> />
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="enableMDNS" checked={data.enableMDNS} onChange={updateFormValue} />} control={
<Checkbox
name="enableMDNS"
checked={data.enableMDNS}
onChange={updateFormValue}
/>
}
label={LL.NETWORK_USE_DNS()} label={LL.NETWORK_USE_DNS()}
/> />
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="enableCORS" checked={data.enableCORS} onChange={updateFormValue} />} control={
<Checkbox
name="enableCORS"
checked={data.enableCORS}
onChange={updateFormValue}
/>
}
label={LL.NETWORK_ENABLE_CORS()} label={LL.NETWORK_ENABLE_CORS()}
/> />
{data.enableCORS && ( {data.enableCORS && (
@@ -259,14 +291,14 @@ const WiFiSettingsForm: FC = () => {
margin="normal" margin="normal"
/> />
)} )}
{data.enableIPv6 !== undefined && (
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="enableIPv6" checked={data.enableIPv6} onChange={updateFormValue} />} control={
label={LL.NETWORK_ENABLE_IPV6()} <Checkbox
name="static_ip_config"
checked={data.static_ip_config}
onChange={updateFormValue}
/> />
)} }
<BlockFormControlLabel
control={<Checkbox name="static_ip_config" checked={data.static_ip_config} onChange={updateFormValue} />}
label={LL.NETWORK_FIXED_IP()} label={LL.NETWORK_FIXED_IP()}
/> />
{data.static_ip_config && ( {data.static_ip_config && (
@@ -325,19 +357,25 @@ const WiFiSettingsForm: FC = () => {
)} )}
{restartNeeded && ( {restartNeeded && (
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}> <MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
<Button startIcon={<PowerSettingsNewIcon />} variant="contained" color="error" onClick={restart}> <Button
startIcon={<PowerSettingsNewIcon />}
variant="contained"
color="error"
onClick={doRestart}
>
{LL.RESTART()} {LL.RESTART()}
</Button> </Button>
</MessageBox> </MessageBox>
)} )}
{!restartNeeded && (selectedNetwork || (dirtyFlags && dirtyFlags.length !== 0)) && ( {!restartNeeded &&
(selectedNetwork || (dirtyFlags && dirtyFlags.length !== 0)) && (
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="secondary"
type="submit" type="submit"
onClick={loadData} onClick={loadData}
> >
@@ -360,11 +398,11 @@ const WiFiSettingsForm: FC = () => {
}; };
return ( return (
<SectionContent title={LL.SETTINGS_OF(LL.NETWORK(1))} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : content()} {restarting ? <RestartMonitor /> : content()}
</SectionContent> </SectionContent>
); );
}; };
export default WiFiSettingsForm; export default NetworkSettings;

View File

@@ -1,4 +1,5 @@
import { createContext } from 'react'; import { createContext } from 'react';
import type { WiFiNetwork } from 'types'; import type { WiFiNetwork } from 'types';
export interface WiFiConnectionContextValue { export interface WiFiConnectionContextValue {
@@ -8,4 +9,6 @@ export interface WiFiConnectionContextValue {
} }
const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContextValue; const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContextValue;
export const WiFiConnectionContext = createContext(WiFiConnectionContextDefaultValue); export const WiFiConnectionContext = createContext(
WiFiConnectionContextDefaultValue
);

View File

@@ -1,34 +1,41 @@
import { useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
// eslint-disable-next-line import/named
import { updateState, useRequest } from 'alova'; import * as NetworkApi from 'api/network';
import { useState, useRef } from 'react';
import { updateState, useRequest } from 'alova/client';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import WiFiNetworkSelector from './WiFiNetworkSelector'; import WiFiNetworkSelector from './WiFiNetworkSelector';
import type { FC } from 'react';
import * as NetworkApi from 'api/network';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
const NUM_POLLS = 10; const NUM_POLLS = 10;
const POLLING_FREQUENCY = 1000; const POLLING_FREQUENCY = 1000;
const WiFiNetworkScanner: FC = () => { const WiFiNetworkScanner = () => {
const pollCount = useRef(0); const pollCount = useRef(0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
const { send: scanNetworks, onComplete: onCompleteScanNetworks } = useRequest(NetworkApi.scanNetworks); // is called on page load to start network scan // is called on page load to start network scan
const { const { send: scanNetworks } = useRequest(NetworkApi.scanNetworks).onComplete(
data: networkList, () => {
send: getNetworkList, pollCount.current = 0;
onSuccess: onSuccessNetworkList setErrorMessage(undefined);
} = useRequest(NetworkApi.listNetworks, { void updateState(NetworkApi.listNetworks(), () => undefined);
immediate: false void getNetworkList();
}); }
);
onSuccessNetworkList((event) => { const { data: networkList, send: getNetworkList } = useRequest(
NetworkApi.listNetworks,
{
immediate: false
}
).onSuccess((event) => {
// is called when network scan is completed
if (!event.data) { if (!event.data) {
const completedPollCount = pollCount.current + 1; const completedPollCount = pollCount.current + 1;
if (completedPollCount < NUM_POLLS) { if (completedPollCount < NUM_POLLS) {
@@ -41,22 +48,17 @@ const WiFiNetworkScanner: FC = () => {
} }
}); });
onCompleteScanNetworks(() => {
pollCount.current = 0;
setErrorMessage(undefined);
updateState('listNetworks', () => undefined);
void getNetworkList();
});
const renderNetworkScanner = () => { const renderNetworkScanner = () => {
if (!networkList) { if (!networkList) {
return <FormLoader message={LL.SCANNING() + '...'} errorMessage={errorMessage} />; return (
<FormLoader message={LL.SCANNING() + '...'} errorMessage={errorMessage} />
);
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}; };
return ( return (
<SectionContent title={LL.NETWORK_SCANNER()}> <SectionContent>
{renderNetworkScanner()} {renderNetworkScanner()}
<ButtonRow> <ButtonRow>
<Button <Button

View File

@@ -1,21 +1,26 @@
import { useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
import WifiIcon from '@mui/icons-material/Wifi'; import WifiIcon from '@mui/icons-material/Wifi';
import { Avatar, Badge, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText, useTheme } from '@mui/material'; import {
import { useContext } from 'react'; Avatar,
Badge,
import { WiFiConnectionContext } from './WiFiConnectionContext'; List,
ListItem,
ListItemAvatar,
ListItemIcon,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material'; import type { Theme } from '@mui/material';
import type { FC } from 'react';
import type { WiFiNetwork, WiFiNetworkList } from 'types';
import { MessageBox } from 'components';
import { MessageBox } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { WiFiNetwork, WiFiNetworkList } from 'types';
import { WiFiEncryptionType } from 'types'; import { WiFiEncryptionType } from 'types';
interface WiFiNetworkSelectorProps { import { WiFiConnectionContext } from './WiFiConnectionContext';
networkList: WiFiNetworkList;
}
export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) => export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN; encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
@@ -39,7 +44,7 @@ export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
case WiFiEncryptionType.WIFI_AUTH_WPA2_WPA3_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA2_WPA3_PSK:
return 'WPA2/WPA3'; return 'WPA2/WPA3';
default: default:
return 'Unknown: ' + encryption_type; return 'Unknown: ' + String(encryption_type);
} }
}; };
@@ -52,21 +57,29 @@ const networkQualityHighlight = ({ rssi }: WiFiNetwork, theme: Theme) => {
return theme.palette.success.main; return theme.palette.success.main;
}; };
const WiFiNetworkSelector: FC<WiFiNetworkSelectorProps> = ({ networkList }) => { const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const theme = useTheme(); const theme = useTheme();
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => ( const renderNetwork = (network: WiFiNetwork) => (
<ListItem key={network.bssid} onClick={() => wifiConnectionContext.selectNetwork(network)}> <ListItem
key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)}
>
<ListItemAvatar> <ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar> <Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={network.ssid} primary={network.ssid}
secondary={ secondary={
'Security: ' + networkSecurityMode(network) + ', Ch: ' + network.channel + ', bssid: ' + network.bssid 'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel +
', bssid: ' +
network.bssid
} }
/> />
<ListItemIcon> <ListItemIcon>

View File

@@ -1,23 +1,23 @@
import CloseIcon from '@mui/icons-material/Close';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
LinearProgress,
Typography,
TextField,
Button
} from '@mui/material';
import { useRequest } from 'alova';
import { useEffect } from 'react'; import { useEffect } from 'react';
import type { FC } from 'react'; import CloseIcon from '@mui/icons-material/Close';
import { dialogStyle } from 'CustomTheme'; import {
import * as SecurityApi from 'api/security'; Box,
import { MessageBox } from 'components'; 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'; import { useI18nContext } from 'i18n/i18n-react';
interface GenerateTokenProps { interface GenerateTokenProps {
@@ -25,30 +25,44 @@ interface GenerateTokenProps {
onClose: () => void; onClose: () => void;
} }
const GenerateToken: FC<GenerateTokenProps> = ({ username, onClose }) => { const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const open = !!username; const open = !!username;
const { data: token, send: generateToken } = useRequest(SecurityApi.generateToken(username), { const { data: token, send: generateToken } = useRequest(
SecurityApi.generateToken(username),
{
immediate: false immediate: false
}); }
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
void generateToken(); void generateToken();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]); }, [open]);
return ( return (
<Dialog sx={dialogStyle} onClose={onClose} open={!!username} fullWidth maxWidth="sm"> <Dialog
sx={dialogStyle}
onClose={onClose}
open={!!username}
fullWidth
maxWidth="sm"
>
<DialogTitle>{LL.ACCESS_TOKEN_FOR() + ' ' + username}</DialogTitle> <DialogTitle>{LL.ACCESS_TOKEN_FOR() + ' ' + username}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
{token ? ( {token ? (
<> <>
<MessageBox message={LL.ACCESS_TOKEN_TEXT()} level="info" my={2} /> <MessageBox message={LL.ACCESS_TOKEN_TEXT()} level="info" my={2} />
<Box mt={2} mb={2}> <Box mt={2} mb={2}>
<TextField label="Token" multiline value={token.token} fullWidth contentEditable={false} /> <TextField
label="Token"
multiline
value={token.token}
fullWidth
contentEditable={false}
/>
</Box> </Box>
</> </>
) : ( ) : (
@@ -59,7 +73,12 @@ const GenerateToken: FC<GenerateTokenProps> = ({ username, onClose }) => {
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button startIcon={<CloseIcon />} variant="outlined" onClick={onClose} color="secondary"> <Button
startIcon={<CloseIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CLOSE()} {LL.CLOSE()}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@@ -1,3 +1,6 @@
import { useContext, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -6,31 +9,44 @@ import EditIcon from '@mui/icons-material/Edit';
import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonAddIcon from '@mui/icons-material/PersonAdd';
import VpnKeyIcon from '@mui/icons-material/VpnKey'; import VpnKeyIcon from '@mui/icons-material/VpnKey';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Button, IconButton, Box } from '@mui/material'; import { Box, Button, IconButton } from '@mui/material';
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { useContext, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import GenerateToken from './GenerateToken';
import UserForm from './UserForm';
import type { FC } from 'react';
import type { SecuritySettings, User } from 'types';
import * as SecurityApi from 'api/security'; import * as SecurityApi from 'api/security';
import { ButtonRow, FormLoader, MessageBox, SectionContent, BlockNavigation } from 'components';
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 {
BlockNavigation,
ButtonRow,
FormLoader,
MessageBox,
SectionContent
} from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType, UserType } from 'types';
import { useRest } from 'utils'; import { useRest } from 'utils';
import { createUserValidator } from 'validators'; import { createUserValidator } from 'validators';
const ManageUsersForm: FC = () => { import GenerateToken from './GenerateToken';
const { loadData, saveData, saving, data, updateDataValue, errorMessage } = useRest<SecuritySettings>({ import User from './User';
const ManageUsers = () => {
const { loadData, saveData, saving, data, updateDataValue, errorMessage } =
useRest<SecuritySettingsType>({
read: SecurityApi.readSecuritySettings, read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings update: SecurityApi.updateSecuritySettings
}); });
const [user, setUser] = useState<User>(); const [user, setUser] = useState<UserType>();
const [creating, setCreating] = useState<boolean>(false); const [creating, setCreating] = useState<boolean>(false);
const [changed, setChanged] = useState<number>(0); const [changed, setChanged] = useState<number>(0);
const [generatingToken, setGeneratingToken] = useState<string>(); const [generatingToken, setGeneratingToken] = useState<string>();
@@ -86,7 +102,7 @@ const ManageUsersForm: FC = () => {
const noAdminConfigured = () => !data.users.find((u) => u.admin); const noAdminConfigured = () => !data.users.find((u) => u.admin);
const removeUser = (toRemove: User) => { const removeUser = (toRemove: UserType) => {
const users = data.users.filter((u) => u.username !== toRemove.username); const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users }); updateDataValue({ ...data, users });
setChanged(changed + 1); setChanged(changed + 1);
@@ -101,7 +117,7 @@ const ManageUsersForm: FC = () => {
}); });
}; };
const editUser = (toEdit: User) => { const editUser = (toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}; };
@@ -112,7 +128,12 @@ const ManageUsersForm: FC = () => {
const doneEditingUser = () => { const doneEditingUser = () => {
if (user) { if (user) {
const users = [...data.users.filter((u: { username: string }) => u.username !== user.username), user]; const users = [
...data.users.filter(
(u: { username: string }) => u.username !== user.username
),
user
];
updateDataValue({ ...data, users }); updateDataValue({ ...data, users });
setUser(undefined); setUser(undefined);
setChanged(changed + 1); setChanged(changed + 1);
@@ -138,12 +159,27 @@ const ManageUsersForm: FC = () => {
setChanged(0); setChanged(0);
}; };
const user_table = data.users.map((u) => ({ ...u, id: u.username })); interface UserType2 {
id: string;
username: string;
password: string;
admin: boolean;
}
// add id to the type, needed for the table
const user_table = data.users.map((u) => ({
...u,
id: u.username
})) as UserType2[];
return ( return (
<> <>
<Table data={{ nodes: user_table }} theme={table_theme} layout={{ custom: true }}> <Table
{(tableList: any) => ( data={{ nodes: user_table }}
theme={table_theme}
layout={{ custom: true }}
>
{(tableList: UserType2[]) => (
<> <>
<Header> <Header>
<HeaderRow> <HeaderRow>
@@ -153,7 +189,7 @@ const ManageUsersForm: FC = () => {
</HeaderRow> </HeaderRow>
</Header> </Header>
<Body> <Body>
{tableList.map((u: any) => ( {tableList.map((u: UserType2) => (
<Row key={u.id} item={u}> <Row key={u.id} item={u}>
<Cell>{u.username}</Cell> <Cell>{u.username}</Cell>
<Cell stiff>{u.admin ? <CheckIcon /> : <CloseIcon />}</Cell> <Cell stiff>{u.admin ? <CheckIcon /> : <CloseIcon />}</Cell>
@@ -179,17 +215,19 @@ const ManageUsersForm: FC = () => {
)} )}
</Table> </Table>
{noAdminConfigured() && <MessageBox level="warning" message={LL.USER_WARNING()} my={2} />} {noAdminConfigured() && (
<MessageBox level="warning" message={LL.USER_WARNING()} my={2} />
)}
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
{changed !== 0 && ( {changed !== 0 && (
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="secondary"
type="submit" type="submit"
onClick={onCancelSubmit} onClick={onCancelSubmit}
> >
@@ -206,12 +244,16 @@ const ManageUsersForm: FC = () => {
{LL.APPLY_CHANGES(changed)} {LL.APPLY_CHANGES(changed)}
</Button> </Button>
</ButtonRow> </ButtonRow>
)}
</Box> </Box>
)}
<Box flexWrap="nowrap" whiteSpace="nowrap"> <Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow> <ButtonRow>
<Button startIcon={<PersonAddIcon />} variant="outlined" color="secondary" onClick={createUser}> <Button
startIcon={<PersonAddIcon />}
variant="outlined"
color="secondary"
onClick={createUser}
>
{LL.ADD(0)} {LL.ADD(0)}
</Button> </Button>
</ButtonRow> </ButtonRow>
@@ -219,7 +261,7 @@ const ManageUsersForm: FC = () => {
</Box> </Box>
<GenerateToken username={generatingToken} onClose={closeGenerateToken} /> <GenerateToken username={generatingToken} onClose={closeGenerateToken} />
<UserForm <User
user={user} user={user}
setUser={setUser} setUser={setUser}
creating={creating} creating={creating}
@@ -232,11 +274,11 @@ const ManageUsersForm: FC = () => {
}; };
return ( return (
<SectionContent title={LL.MANAGE_USERS()} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );
}; };
export default ManageUsersForm; export default ManageUsers;

View File

@@ -0,0 +1,32 @@
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,20 +1,27 @@
import { useContext, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { useContext, useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import type { SecuritySettings } from 'types';
import * as SecurityApi from 'api/security'; import * as SecurityApi from 'api/security';
import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField, BlockNavigation } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
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 { useI18nContext } from 'i18n/i18n-react';
import type { SecuritySettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators'; import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
const SecuritySettingsForm: FC = () => { const SecuritySettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -29,14 +36,19 @@ const SecuritySettingsForm: FC = () => {
blocker, blocker,
saveData, saveData,
errorMessage errorMessage
} = useRest<SecuritySettings>({ } = useRest<SecuritySettingsType>({
read: SecurityApi.readSecuritySettings, read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings update: SecurityApi.updateSecuritySettings
}); });
const authenticatedContext = useContext(AuthenticatedContext); const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue); const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -49,8 +61,8 @@ const SecuritySettingsForm: FC = () => {
await validate(SECURITY_SETTINGS_VALIDATOR, data); await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
}; };
@@ -73,7 +85,7 @@ const SecuritySettingsForm: FC = () => {
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
disabled={saving} disabled={saving}
variant="outlined" variant="outlined"
color="primary" color="secondary"
type="submit" type="submit"
onClick={loadData} onClick={loadData}
> >
@@ -96,11 +108,11 @@ const SecuritySettingsForm: FC = () => {
}; };
return ( return (
<SectionContent title={LL.SETTINGS_OF(LL.SECURITY(1))} titleGutter> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );
}; };
export default SecuritySettingsForm; export default SecuritySettings;

View File

@@ -1,32 +1,48 @@
import { useEffect, useState } from 'react';
import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import PersonAddIcon from '@mui/icons-material/PersonAdd'; import PersonAddIcon from '@mui/icons-material/PersonAdd';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle
} from '@mui/material';
import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import { dialogStyle } from 'CustomTheme';
import { useState, useEffect } from 'react';
import type Schema from 'async-validator'; import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator'; import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react'; import {
BlockFormControlLabel,
import type { User } from 'types'; ValidatedPasswordField,
import { dialogStyle } from 'CustomTheme'; ValidatedTextField
import { BlockFormControlLabel, ValidatedPasswordField, ValidatedTextField } from 'components'; } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { UserType } from 'types';
import { updateValue } from 'utils'; import { updateValue } from 'utils';
import { validate } from 'validators'; import { validate } from 'validators';
interface UserFormProps { interface UserFormProps {
creating: boolean; creating: boolean;
validator: Schema; validator: Schema;
user?: UserType;
user?: User; setUser: React.Dispatch<React.SetStateAction<UserType | undefined>>;
setUser: React.Dispatch<React.SetStateAction<User | undefined>>;
onDoneEditing: () => void; onDoneEditing: () => void;
onCancelEditing: () => void; onCancelEditing: () => void;
} }
const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDoneEditing, onCancelEditing }) => { const User: FC<UserFormProps> = ({
creating,
validator,
user,
setUser,
onDoneEditing,
onCancelEditing
}) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const updateFormValue = updateValue(setUser); const updateFormValue = updateValue(setUser);
@@ -45,14 +61,20 @@ const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDon
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, user); await validate(validator, user);
onDoneEditing(); onDoneEditing();
} catch (errors: any) { } catch (error) {
setFieldErrors(errors); setFieldErrors(error as ValidateFieldsError);
} }
} }
}; };
return ( return (
<Dialog sx={dialogStyle} onClose={onCancelEditing} open={!!user} fullWidth maxWidth="sm"> <Dialog
sx={dialogStyle}
onClose={onCancelEditing}
open={!!user}
fullWidth
maxWidth="sm"
>
{user && ( {user && (
<> <>
<DialogTitle id="user-form-dialog-title"> <DialogTitle id="user-form-dialog-title">
@@ -81,12 +103,23 @@ const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDon
margin="normal" margin="normal"
/> />
<BlockFormControlLabel <BlockFormControlLabel
control={<Checkbox name="admin" checked={user.admin} onChange={updateFormValue} />} control={
<Checkbox
name="admin"
checked={user.admin}
onChange={updateFormValue}
/>
}
label={LL.IS_ADMIN(1)} label={LL.IS_ADMIN(1)}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={onCancelEditing} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onCancelEditing}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
@@ -104,4 +137,4 @@ const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDon
); );
}; };
export default UserForm; export default User;

View File

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,127 @@
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

@@ -0,0 +1,206 @@
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,20 +1,30 @@
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh';
import ReportIcon from '@mui/icons-material/Report'; import ReportIcon from '@mui/icons-material/Report';
import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff'; import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, useTheme } from '@mui/material'; import {
import { useRequest } from 'alova'; Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material'; import type { Theme } from '@mui/material';
import type { FC } from 'react';
import type { MqttStatus } from 'types';
import * as MqttApi from 'api/mqtt'; import * as MqttApi from 'api/mqtt';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useAutoRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types'; import { MqttDisconnectReason } from 'types';
export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => { export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType,
theme: Theme
) => {
if (!enabled) { if (!enabled) {
return theme.palette.info.main; return theme.palette.info.main;
} }
@@ -24,27 +34,38 @@ export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: T
return theme.palette.error.main; return theme.palette.error.main;
}; };
export const mqttPublishHighlight = ({ mqtt_fails }: MqttStatus, theme: Theme) => { export const mqttPublishHighlight = (
{ mqtt_fails }: MqttStatusType,
theme: Theme
) => {
if (mqtt_fails === 0) return theme.palette.success.main; if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main; if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main; return theme.palette.error.main;
}; };
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatus, theme: Theme) => { export const mqttQueueHighlight = (
{ mqtt_queued }: MqttStatusType,
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main; if (mqtt_queued <= 1) return theme.palette.success.main;
return theme.palette.warning.main; return theme.palette.warning.main;
}; };
const MqttStatusForm: FC = () => { const MqttStatus = () => {
const { data: data, send: loadData, error } = useRequest(MqttApi.readMqttStatus); const {
data,
send: loadData,
error
} = useAutoRequest(MqttApi.readMqttStatus, { pollingTime: 3000 });
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF('MQTT'));
const theme = useTheme(); const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatus) => { const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) { if (!enabled) {
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
} }
@@ -54,7 +75,7 @@ const MqttStatusForm: FC = () => {
return LL.DISCONNECTED() + (connect_count > 1 ? ' (' + connect_count + ')' : ''); return LL.DISCONNECTED() + (connect_count > 1 ? ' (' + connect_count + ')' : '');
}; };
const disconnectReason = ({ disconnect_reason }: MqttStatus) => { const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
switch (disconnect_reason) { switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED: case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected'; return 'TCP disconnected';
@@ -90,7 +111,10 @@ const MqttStatusForm: FC = () => {
<ReportIcon /> <ReportIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.DISCONNECT_REASON()} secondary={disconnectReason(data)} /> <ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={disconnectReason(data)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</> </>
@@ -124,7 +148,6 @@ const MqttStatusForm: FC = () => {
); );
return ( return (
<>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -137,20 +160,10 @@ const MqttStatusForm: FC = () => {
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()} {data.enabled && renderConnectionStatus()}
</List> </List>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
{LL.REFRESH()}
</Button>
</ButtonRow>
</>
); );
}; };
return ( return <SectionContent>{content()}</SectionContent>;
<SectionContent title={LL.STATUS_OF('MQTT')} titleGutter>
{content()}
</SectionContent>
);
}; };
export default MqttStatusForm; export default MqttStatus;

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import RefreshIcon from '@mui/icons-material/Refresh';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update'; import UpdateIcon from '@mui/icons-material/Update';
import { import {
@@ -18,29 +20,50 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
TextField, TextField,
useTheme, Typography,
Typography useTheme
} from '@mui/material'; } from '@mui/material';
import { useRequest } from 'alova';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import type { Theme } from '@mui/material'; import type { Theme } from '@mui/material';
import type { FC } from 'react';
import type { NTPStatus } from 'types';
import { dialogStyle } from 'CustomTheme';
import * as NTPApi from 'api/ntp'; import * as NTPApi from 'api/ntp';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { dialogStyle } from 'CustomTheme';
import { useAutoRequest, useRequest } from 'alova/client';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { NTPStatusType, Time } from 'types';
import { NTPSyncStatus } from 'types'; import { NTPSyncStatus } from 'types';
import { formatDateTime, formatLocalDateTime } from 'utils'; import { formatDateTime, formatLocalDateTime } from 'utils';
export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE; const NTPStatus = () => {
export const isNtpEnabled = ({ status }: NTPStatus) => status !== NTPSyncStatus.NTP_DISABLED; const {
data,
send: loadData,
error
} = useAutoRequest(NTPApi.readNTPStatus, { pollingTime: 3000 });
export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => { const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF('NTP'));
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
NTPApi.updateTime;
const isNtpActive = ({ status }: NTPStatusType) =>
status === NTPSyncStatus.NTP_ACTIVE;
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) { switch (status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -53,23 +76,8 @@ export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
} }
}; };
const NTPStatusForm: FC = () => { const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
const { data: data, send: loadData, error } = useRequest(NTPApi.readNTPStatus); setLocalTime(event.target.value);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { me } = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
const { send: updateTime } = useRequest((local_time) => NTPApi.updateTime(local_time), {
immediate: false
});
NTPApi.updateTime;
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value);
const openSetTime = () => { const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
@@ -78,7 +86,7 @@ const NTPStatusForm: FC = () => {
const theme = useTheme(); const theme = useTheme();
const ntpStatus = ({ status }: NTPStatus) => { const ntpStatus = ({ status }: NTPStatusType) => {
switch (status) { switch (status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
@@ -109,26 +117,37 @@ const NTPStatusForm: FC = () => {
}; };
const renderSetTimeDialog = () => ( const renderSetTimeDialog = () => (
<Dialog sx={dialogStyle} open={settingTime} onClose={() => setSettingTime(false)}> <Dialog
sx={dialogStyle}
open={settingTime}
onClose={() => setSettingTime(false)}
>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle> <DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}> <Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography> <Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box> </Box>
<TextField <TextField
label={LL.LOCAL_TIME()} label={LL.LOCAL_TIME(0)}
type="datetime-local" type="datetime-local"
value={localTime} value={localTime}
onChange={updateLocalTime} onChange={updateLocalTime}
disabled={processing} disabled={processing}
fullWidth fullWidth
InputLabelProps={{ slotProps={{
inputLabel: {
shrink: true shrink: true
}
}} }}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={() => setSettingTime(false)} color="secondary"> <Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setSettingTime(false)}
color="secondary"
>
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>
<Button <Button
@@ -180,7 +199,10 @@ const NTPStatusForm: FC = () => {
<AccessTimeIcon /> <AccessTimeIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.LOCAL_TIME()} secondary={formatDateTime(data.local_time)} /> <ListItemText
primary={LL.LOCAL_TIME(0)}
secondary={formatDateTime(data.local_time)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -189,22 +211,23 @@ const NTPStatusForm: FC = () => {
<SwapVerticalCircleIcon /> <SwapVerticalCircleIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.UTC_TIME()} secondary={formatDateTime(data.utc_time)} /> <ListItemText
primary={LL.UTC_TIME()}
secondary={formatDateTime(data.utc_time)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1}> {data && !isNtpActive(data) && (
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
{LL.REFRESH()}
</Button>
</ButtonRow>
</Box>
{me.admin && data && !isNtpActive(data) && (
<Box flexWrap="nowrap" whiteSpace="nowrap"> <Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow> <ButtonRow>
<Button onClick={openSetTime} variant="outlined" color="primary" startIcon={<AccessTimeIcon />}> <Button
onClick={openSetTime}
variant="outlined"
color="primary"
startIcon={<AccessTimeIcon />}
>
{LL.SET_TIME(0)} {LL.SET_TIME(0)}
</Button> </Button>
</ButtonRow> </ButtonRow>
@@ -216,11 +239,7 @@ const NTPStatusForm: FC = () => {
); );
}; };
return ( return <SectionContent>{content()}</SectionContent>;
<SectionContent title={LL.STATUS_OF('NTP')} titleGutter>
{content()}
</SectionContent>
);
}; };
export default NTPStatusForm; export default NTPStatus;

View File

@@ -0,0 +1,223 @@
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as NetworkApi from 'api/network';
import { useAutoRequest } from 'alova/client';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types';
const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return theme.palette.info.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return theme.palette.success.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return theme.palette.error.main;
default:
return theme.palette.warning.main;
}
};
const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
if (rssi <= -85) {
return theme.palette.error.main;
} else if (rssi <= -75) {
return theme.palette.warning.main;
}
return theme.palette.success.main;
};
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) {
return 'none';
}
return dns_ip_1 + (!dns_ip_2 || dns_ip_2 === '0.0.0.0' ? '' : ', ' + dns_ip_2);
};
const IPs = (status: NetworkStatusType) => {
if (
!status.local_ipv6 ||
status.local_ipv6 === '0000:0000:0000:0000:0000:0000:0000:0000' ||
status.local_ipv6 === '::'
) {
return status.local_ip;
}
if (!status.local_ip || status.local_ip === '0.0.0.0') {
return status.local_ipv6;
}
return status.local_ip + ', ' + status.local_ipv6;
};
const NetworkStatus = () => {
const {
data,
send: loadData,
error
} = useAutoRequest(NetworkApi.readNetworkStatus, { pollingTime: 3000 });
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(LL.NETWORK(1)));
const theme = useTheme();
const networkStatus = ({ status }: NetworkStatusType) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<GiteIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HOSTNAME()} secondary={data.hostname} />
</ListItem>
<Divider variant="inset" component="li" />
{isWiFi(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SSID (RSSI)"
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
{isConnected(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={IPs(data)} />
</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>#</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_SUBNET()}
secondary={data.subnet_mask}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_GATEWAY()}
secondary={data.gateway_ip || 'none'}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_DNS()}
secondary={dnsServers(data)}
/>
</ListItem>
</>
)}
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default NetworkStatus;

View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import {
Box,
CircularProgress,
Dialog,
DialogContent,
Typography
} from '@mui/material';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useAutoRequest } from 'alova/client';
import MessageBox from 'components/MessageBox';
import { useI18nContext } from 'i18n/i18n-react';
const RestartMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const { LL } = useI18nContext();
let count = 0;
const { data } = useAutoRequest(readSystemStatus, {
pollingTime: 1000,
force: true,
initialData: { status: 'Getting ready...' },
async middleware(_, next) {
if (count++ >= 1) {
// skip first request (1 second) to allow AsyncWS to send its response
await next();
}
}
})
.onSuccess((event) => {
if (event.data.status === 'ready' || event.data.status === undefined) {
document.location.href = '/';
}
})
.onError((error) => {
setErrorMessage(error.message);
});
return (
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
<DialogContent dividers>
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column">
<Typography
color="secondary"
variant="h6"
fontWeight={400}
textAlign="center"
>
{data?.status === 'uploading'
? LL.WAIT_FIRMWARE()
: data?.status === 'restarting'
? LL.APPLICATION_RESTARTING()
: data?.status === 'ready'
? LL.RESTARTING_PRE()
: LL.RESTARTING_POST()}
&hellip;
</Typography>
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
{LL.PLEASE_WAIT()}
</Typography>
{errorMessage ? (
<MessageBox my={2} level="error" message={errorMessage} />
) : (
<Box py={2}>
<CircularProgress size={32} />
</Box>
)}
</Box>
</DialogContent>
</Dialog>
);
};
export default RestartMonitor;

View File

@@ -0,0 +1,346 @@
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TimerIcon from '@mui/icons-material/Timer';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useAutoRequest, useRequest } from 'alova/client';
import { type APIcall, busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
import RestartMonitor from './RestartMonitor';
const SystemStatus = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(''));
const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useAutoRequest(readSystemStatus, {
initialData: [],
pollingTime: 3000,
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
const theme = useTheme();
const formatDurationSec = (duration_sec: number) => {
const days = Math.trunc((duration_sec * 1000) / 86400000);
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 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 }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const busStatus = () => {
if (data) {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return (
'EMS ' +
LL.CONNECTED(0) +
' (' +
formatDurationSec(data.bus_uptime) +
')'
);
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
}
}
return 'EMS state unknown';
};
const busStatusHighlight = () => {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
case busConnectionStatus.BUS_STATUS_CONNECTED:
return theme.palette.success.main;
case busConnectionStatus.BUS_STATUS_OFFLINE:
return theme.palette.error.main;
default:
return theme.palette.warning.main;
}
};
const ntpStatus = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
return LL.ACTIVE();
default:
return LL.UNKNOWN();
}
};
const ntpStatusHighlight = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const networkStatusHighlight = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return theme.palette.info.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return theme.palette.success.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return theme.palette.error.main;
default:
return theme.palette.warning.main;
}
};
const networkStatus = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const doRestart = async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const renderRestartDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={() => setConfirmRestart(false)}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmRestart(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<TimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight()}
label={LL.DATA_TRAFFIC()}
text={busStatus()}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
label={LL.NETWORK(1)}
text={networkStatus()}
to="/status/network"
/>
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight()}
label="NTP"
text={ntpStatus()}
to="/status/ntp"
/>
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={LL.VIEW_LOG()}
to="/status/log"
/>
</List>
{renderRestartDialog()}
</>
);
};
return (
<SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
);
};
export default SystemStatus;

View File

@@ -0,0 +1,358 @@
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
IconButton,
MenuItem,
TextField,
styled
} from '@mui/material';
import Grid from '@mui/material/Grid2';
import { API } from 'api/app';
import { fetchLogES, readLogSettings, updateLogSettings } from 'api/system';
import { useRequest, useSSE } from 'alova/client';
import {
BlockFormControlLabel,
BlockNavigation,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
import { updateValueDirty, useRest } from 'utils';
const TextColors = {
[LogLevel.ERROR]: '#ff0000', // red
[LogLevel.WARNING]: '#ff0000', // red
[LogLevel.NOTICE]: '#ffffff', // white
[LogLevel.INFO]: '#ffcc00', // yellow
[LogLevel.DEBUG]: '#00ffff', // cyan
[LogLevel.TRACE]: '#00ffff' // cyan
};
const LogEntryLine = styled('span')(
({ details: { level } }: { details: { level: LogLevel } }) => ({
color: TextColors[level]
})
);
const topOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
const leftOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().left || 0;
const levelLabel = (level: LogLevel) => {
switch (level) {
case LogLevel.ERROR:
return 'ERROR';
case LogLevel.WARNING:
return 'WARNING';
case LogLevel.NOTICE:
return 'NOTICE';
case LogLevel.INFO:
return 'INFO';
case LogLevel.DEBUG:
return 'DEBUG';
case LogLevel.TRACE:
return 'TRACE';
default:
return '';
}
};
const SystemLog = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.LOG_OF(LL.SYSTEM(0)));
const {
loadData,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<LogSettings>({
read: readLogSettings,
update: updateLogSettings
});
const { send } = useRequest(
(data: string) => API({ device: 'system', cmd: 'read', id: 0, data: data }),
{
immediate: false
}
);
const [readValue, setReadValue] = useState('');
const [readOpen, setReadOpen] = useState(false);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [autoscroll, setAutoscroll] = useState(true);
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
useSSE(fetchLogES, {
immediate: true,
interceptByGlobalResponded: false
})
.onMessage((message: { id: number; data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => [...log, logentry]);
})
.onError(() => {
toast.error('No connection to Log service');
});
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return data?.compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return data?.compact ? label : label.padEnd(7, '\xa0');
};
const onDownload = () => {
let result = '';
for (const i of logEntries) {
result +=
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
}
const a = document.createElement('a');
a.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(result)
);
a.setAttribute('download', 'log.txt');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const saveSettings = async () => {
await saveData();
};
// handle scrolling
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logEntries.length && autoscroll) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}, [logEntries.length]);
const sendReadCommand = () => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
}
if (readValue.split(' ').filter((word) => word !== '').length > 1) {
void send(readValue);
setReadOpen(false);
setReadValue('');
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<Grid container spacing={2} alignItems="center">
<Grid>
<TextField
name="level"
label={LL.LOG_LEVEL()}
value={data.level}
sx={{ width: '10ch' }}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERROR</MenuItem>
<MenuItem value={4}>WARNING</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</TextField>
</Grid>
{data.psram && (
<Grid>
<TextField
name="max_messages"
label={LL.BUFFER_SIZE()}
value={data.max_messages}
sx={{ width: '15ch' }}
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={25}>25</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={75}>75</MenuItem>
<MenuItem value={100}>100</MenuItem>
</TextField>
</Grid>
)}
<Grid>
<BlockFormControlLabel
control={
<Checkbox
checked={data.compact}
onChange={updateFormValue}
name="compact"
/>
}
label={LL.COMPACT()}
/>
<BlockFormControlLabel
control={
<Checkbox
checked={autoscroll}
onChange={() => setAutoscroll(!autoscroll)}
name="autoscroll"
/>
}
label={LL.AUTO_SCROLL()}
/>
</Grid>
<Grid>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="secondary"
onClick={onDownload}
>
{LL.EXPORT()}
</Button>
{dirtyFlags && dirtyFlags.length !== 0 && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveSettings}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
)}
</Grid>
{readOpen ? (
<Box
component="form"
sx={{ display: 'flex', alignItems: 'flex-end' }}
onSubmit={(e) => {
e.preventDefault();
sendReadCommand();
}}
>
<IconButton
disableRipple
onClick={() => {
setReadOpen(false);
setReadValue('');
}}
>
<PlayArrowIcon color="primary" sx={{ my: 2.5 }} />
</IconButton>
<TextField
value={readValue}
onChange={(e) => {
const value = e.target.value;
if (value !== '' && !ALPHA_NUMERIC_DASH_REGEX.test(value)) {
return;
}
setReadValue(value);
}}
focused={true}
size="small"
label="Send Read command" // doesn't need translating - developer only
helperText="<deviceID> <typeID> [offset] [len]"
/>
</Box>
) : (
<>
{data.developer_mode && (
<IconButton onClick={sendReadCommand}>
<PlayArrowIcon color="primary" />
</IconButton>
)}
</>
)}
</Grid>
<Box
sx={{
backgroundColor: 'black',
overflowY: 'scroll',
position: 'absolute',
right: 18,
bottom: 18,
left: () => leftOffset(),
top: () => topOffset(),
p: 1
}}
>
{logEntries.map((e) => (
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<LogEntryLine details={{ level: e.l }} key={e.i}>
{e.m}
</LogEntryLine>
</div>
))}
<div ref={ref} />
</Box>
</>
);
};
return (
<SectionContent id="log-window">
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);
};
export default SystemLog;

View File

@@ -0,0 +1,26 @@
<svg width="20mm" height="30.146mm" version="1.1" viewBox="0 0 20 30.146" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-86.195 -104.7)">
<g transform="matrix(.53414 0 0 .53414 70.913 49.568)">
<g fill="#fff">
<path d="m66.034 122.7h-37.398a18.722 18.722 0 0 0-0.02643 0.52631 18.722 18.722 0 0 0 18.722 18.722 18.722 18.722 0 0 0 18.722-18.722 18.722 18.722 0 0 0-0.01882-0.52631z" color="#000000" stroke-width=".15037" style="-inkscape-stroke:none"/>
<path d="m30.943 141.6v2h32.174v-2z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m40.738 142-6.9004 11.951 1.7305 1 6.9023-11.951z" color="#000000" style="-inkscape-stroke:none"/>
<g fill="#fff" fill-rule="evenodd">
<path d="m32.395 158.45-0.0041-7.9929 6.9282 4z" color="#000000" stroke-width=".8pt" style="-inkscape-stroke:none"/>
<path d="m31.857 149.54 0.0039 9.8398 0.80078-0.46094 7.7246-4.4551zm1.0664 1.8457 5.3281 3.0762-5.3242 3.0723z" color="#000000" style="-inkscape-stroke:none"/>
</g>
<path d="m56.547 103.22-1.5645 1.2461s0.33514 0.43498 0.48828 1.0391c0.15315 0.60408 0.18926 1.209-0.57617 1.9688-1.1965 1.1876-1.5061 2.5227-1.4004 3.4902 0.10574 0.96749 0.5918 1.6426 0.5918 1.6426l1.6211-1.1699s-0.17552-0.2403-0.22461-0.68946c-0.04909-0.44915-0.0062-1.0331 0.82031-1.8535 1.2576-1.2483 1.3774-2.8142 1.1074-3.8789s-0.86328-1.7949-0.86328-1.7949z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m48.918 103.22-1.5645 1.2461s0.33513 0.43498 0.48828 1.0391 0.18926 1.209-0.57617 1.9688c-1.1965 1.1876-1.5042 2.5227-1.3984 3.4902 0.10574 0.96749 0.58984 1.6426 0.58984 1.6426l1.623-1.1699s-0.17552-0.2403-0.22461-0.68946c-0.04909-0.44915-0.0062-1.0331 0.82031-1.8535 1.2576-1.2483 1.3754-2.8142 1.1055-3.8789s-0.86328-1.7949-0.86328-1.7949z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m41.291 103.22-1.5664 1.2461s0.33709 0.43498 0.49024 1.0391 0.18926 1.209-0.57617 1.9688c-1.1965 1.1876-1.5061 2.5227-1.4004 3.4902s0.5918 1.6426 0.5918 1.6426l1.6211-1.1699s-0.17552-0.2403-0.22461-0.68946c-0.04909-0.44916-0.0062-1.0331 0.82031-1.8535 1.2576-1.2483 1.3754-2.8142 1.1055-3.8789-0.26993-1.0647-0.86133-1.7949-0.86133-1.7949z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m54.896 113.47c-1.4548 0-2.706 0.87699-3.5293 2.0957-0.82334 1.2187-1.291 2.8147-1.291 4.5586v8e-3c5.87e-4 0.0787 0.0036 0.15349 0.0059 0.22656l1.998-0.0605c-0.0019-0.0604-0.0034-0.11856-0.0039-0.17383v-2e-3c-1.6e-5 -2e-3 1.4e-5 -4e-3 0-6e-3 0.0015-1.3755 0.38921-2.6056 0.94726-3.4316 0.55917-0.82768 1.2182-1.2148 1.873-1.2148 0.65485 0 1.3139 0.38716 1.873 1.2148s0.94727 2.0607 0.94727 3.4394h2c0-1.7439-0.46768-3.3399-1.291-4.5586-0.82334-1.2187-2.0745-2.0957-3.5293-2.0957z" color="#000000" style="-inkscape-stroke:none;paint-order:stroke fill markers"/>
<path d="m47.27 113.47c-1.4548 0-2.706 0.87699-3.5293 2.0957-0.82334 1.2187-1.291 2.8147-1.291 4.5586h2c0-1.3788 0.38809-2.6118 0.94726-3.4394 0.55917-0.82768 1.2182-1.2148 1.873-1.2148 0.65484-1e-5 1.3119 0.38716 1.8711 1.2148 0.55802 0.82598 0.94567 2.0563 0.94727 3.4316-0.0018 0.0584-0.0035 0.11387-0.0059 0.16406l1.998 0.0957c0.0037-0.0788 0.0057-0.15249 0.0078-0.22071l2e-3 -0.0156v-0.0156c0-1.7439-0.46767-3.3399-1.291-4.5586-0.82334-1.2187-2.0745-2.0957-3.5293-2.0957z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m39.641 113.47c-1.4548 0-2.706 0.87699-3.5293 2.0957-0.82334 1.2187-1.291 2.8147-1.291 4.5586v0.012c8.96e-4 0.0721 0.0037 0.15312 0.0078 0.24024l1.9961-0.0957c-0.0021-0.0456-0.0031-0.0976-0.0039-0.15625v-2e-3c3.87e-4 -1.378 0.38837-2.6102 0.94727-3.4375 0.55917-0.82768 1.2182-1.2148 1.873-1.2148 0.65484 0 1.3139 0.38716 1.873 1.2148s0.94726 2.0607 0.94726 3.4394h2c0-1.7439-0.46768-3.3399-1.291-4.5586s-2.0745-2.0957-3.5293-2.0957z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m57.557 119.33v2h5.8418v-2z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m31.137 119.33v2h5.8418v-2z" color="#000000" style="-inkscape-stroke:none"/>
</g>
<path d="m35.826 119.95v0.77149h22.883v-0.77149z" color="#000000" fill-opacity="0" style="-inkscape-stroke:none"/>
<path d="m59.064 150.73c-2.4534 0-4.4629 2.0115-4.4629 4.4648s2.0095 4.4629 4.4629 4.4629 4.4629-2.0095 4.4629-4.4629-2.0095-4.4648-4.4629-4.4648zm0 2c1.3725 0 2.4629 1.0924 2.4629 2.4648s-1.0904 2.4629-2.4629 2.4629-2.4629-1.0904-2.4629-2.4629 1.0904-2.4648 2.4629-2.4648z" color="#000000" fill="#fff" style="-inkscape-stroke:none;paint-order:stroke fill markers"/>
<path d="m54.932 142.14-1.8359 0.79687 3.752 8.6582 1.8359-0.79493z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,6 +1,7 @@
import type { FC } from 'react';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import type { BoxProps } from '@mui/material'; import type { BoxProps } from '@mui/material';
import type { FC } from 'react';
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => ( const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => (
<Box <Box

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